2025-11-02 10:21:28 -08:00

647 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml.Linq;
namespace PropresentorScript
{
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml.Linq;
class Program
{
static void Main(string[] args)
{
// 用法dotnet run "path/to/song.txt"
string inputText;
if (args.Length > 0 && File.Exists(args[0]))
{
inputText = File.ReadAllText(args[0]);
}
else
{
// 沒給檔案就用內建範例(可自行刪除)
inputText = @"向我神
To Our God
[Verse 1]
從塵土灰燼 祢愛贖回我
Up from the ashes Your love has brought us
脫離黑暗中 進入光明
Out of the darkness into the light;
挪去我憂傷
Lifting our sorrows
承受我重擔 醫治我心
Bearing our burdens healing our hearts;
[Chorus]
向我神 我揚聲歌唱
To our God we lift up one voice
向我神 我高舉雙手
To our God we lift up one song;
向我神 同心來宣揚
To our God we lift up one voice
高唱 哈利路亞
Singing Hallelujah;";
}
Song song = ParseSong(inputText);
XDocument pro6 = BuildPro6(song, 1920, 1080);
string safeTitle = MakeSafeFileName(song.TitleZh ?? song.Title);
string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeTitle + ".pro6");
pro6.Save(outputPath);
Console.WriteLine("Wrote: " + outputPath);
}
// ---------------- Models ----------------
class Section
{
public string Name { get; set; } = "";
// 每個元素為一張「原始」投影片的多行文字(以 \n 分隔);後續會再自動分頁
public List<string> Slides { get; set; } = new List<string>();
}
class Song
{
public string Title { get; set; } = "Untitled"; // 後備
public string? TitleZh { get; set; } // 中文歌名(若有)
public string? TitleEn { get; set; } // 英文歌名(若有)
public List<Section> Sections { get; set; } = new List<Section>();
public string FooterText(string sectionName)
{
string baseTitle = !string.IsNullOrWhiteSpace(TitleEn) && !string.IsNullOrWhiteSpace(TitleZh)
? (TitleZh + " / " + TitleEn)
: (TitleZh ?? Title);
return baseTitle + " - " + sectionName;
}
}
// ---------------- Parsing ----------------
// 規則:
// - 第一個非空行 = 標題1可能是中或英
// - 第二個非空行若不是 [Header]則當作標題2
// - [xxx] 開頭代表段落標頭;其後文字累積,遇到 ; 或 就分成新投影片
static Song ParseSong(string raw)
{
string[] lines = raw.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
int i = 0;
// 標題1
while (i < lines.Length && string.IsNullOrWhiteSpace(lines[i])) i++;
string title1 = (i < lines.Length) ? lines[i].Trim() : "Untitled";
i++;
// 標題2若下一個非空行不是 header
while (i < lines.Length && string.IsNullOrWhiteSpace(lines[i])) i++;
string? title2 = null;
if (i < lines.Length && !IsHeader(lines[i].Trim(), out _))
{
title2 = lines[i].Trim();
i++;
}
Song song = new Song();
if (ContainsCjk(title1))
{
song.TitleZh = title1;
song.Title = title1;
if (!string.IsNullOrWhiteSpace(title2)) song.TitleEn = title2;
}
else
{
song.Title = title1;
if (!string.IsNullOrWhiteSpace(title2))
{
if (ContainsCjk(title2)) song.TitleZh = title2;
else song.TitleEn = title2;
}
}
List<Section> sections = new List<Section>();
Section? current = null;
List<string> slideBuffer = new List<string>();
void FlushSlide()
{
if (current == null) return;
string joined = string.Join("\n", slideBuffer).Trim();
if (!string.IsNullOrWhiteSpace(joined))
current.Slides.Add(joined);
slideBuffer.Clear();
}
void FlushSection()
{
if (current == null) return;
if (slideBuffer.Count > 0) FlushSlide();
sections.Add(current);
current = null;
}
// 跳過 header 前的空白
while (i < lines.Length && string.IsNullOrWhiteSpace(lines[i])) i++;
for (; i < lines.Length; i++)
{
string rawLine = lines[i] ?? "";
string line = rawLine.TrimEnd();
string header;
if (IsHeader(line, out header))
{
FlushSection();
current = new Section { Name = header };
slideBuffer.Clear();
continue;
}
if (current == null) continue; // 忽略 header 之前的散行
// 支援多個分號;每遇到 ; 或 分頁
string remaining = line;
while (true)
{
int idx = IndexOfBreak(remaining); // ; 或
if (idx == -1)
{
if (!string.IsNullOrWhiteSpace(remaining))
slideBuffer.Add(remaining);
break;
}
else
{
string before = remaining.Substring(0, idx);
if (!string.IsNullOrWhiteSpace(before))
slideBuffer.Add(before);
// 分頁
FlushSlide();
// 繼續處理分號之後的內容
remaining = remaining.Substring(idx + 1);
}
}
}
// 收尾
FlushSection();
song.Sections = sections;
return song;
}
static bool IsHeader(string line, out string name)
{
name = "";
if (string.IsNullOrWhiteSpace(line)) return false;
line = line.Trim();
if (line.Length >= 3 && line.StartsWith("[") && line.EndsWith("]"))
{
name = line.Substring(1, line.Length - 2).Trim();
return name.Length > 0;
}
return false;
}
static int IndexOfBreak(string s)
{
if (string.IsNullOrEmpty(s)) return -1;
int a = s.IndexOf(';'); // 半形
int b = s.IndexOf(''); // 全形
if (a == -1) return b;
if (b == -1) return a;
return Math.Min(a, b);
}
static bool ContainsCjk(string s)
{
foreach (char ch in s)
{
// 基本 CJK 區段(足夠處理歌詞)
if (ch >= 0x4E00 && ch <= 0x9FFF) return true;
}
return false;
}
static string MakeSafeFileName(string s)
{
char[] invalid = Path.GetInvalidFileNameChars();
char[] arr = s.Select(c => invalid.Contains(c) ? '_' : c).ToArray();
string cleaned = new string(arr);
return string.IsNullOrWhiteSpace(cleaned) ? "Presentation" : cleaned;
}
// ---------------- Pro6 XML building ----------------
static XDocument BuildPro6(Song song, int width, int height)
{
XElement root = new XElement("RVPresentationDocument",
new XAttribute("height", height),
new XAttribute("width", width),
new XAttribute("docType", "0"),
new XAttribute("versionNumber", "600"),
new XAttribute("usedCount", "0"),
new XAttribute("backgroundColor", "0 0 0 1"),
new XAttribute("drawingBackgroundColor", "false"),
new XAttribute("CCLIDisplay", "false"),
new XAttribute("lastDateUsed", ""),
new XAttribute("selectedArrangementID", ""),
new XAttribute("category", "Presentation"),
new XAttribute("resourcesDirectory", ""),
new XAttribute("notes", ""),
new XAttribute("CCLISongTitle", song.TitleZh ?? song.Title),
new XAttribute("chordChartPath", ""),
new XAttribute("os", "1"),
new XAttribute("buildNumber", "6016")
);
// Minimal timeline
root.Add(new XElement("RVTimeline",
new XAttribute("timeOffset", "0"),
new XAttribute("duration", "0"),
new XAttribute("selectedMediaTrackIndex", "-1"),
new XAttribute("loop", "false"),
new XAttribute("rvXMLIvarName", "timeline"),
new XElement("array", new XAttribute("rvXMLIvarName", "timeCues")),
new XElement("array", new XAttribute("rvXMLIvarName", "mediaTracks"))
));
XElement groupsArray = new XElement("array", new XAttribute("rvXMLIvarName", "groups"));
root.Add(groupsArray);
// Intro group at top
groupsArray.Add(BuildIntroGroup(song, width, height));
// Section groups
foreach (Section sec in song.Sections)
{
XElement group = new XElement("RVSlideGrouping",
new XAttribute("name", sec.Name),
new XAttribute("color", "1 1 1 0"),
new XAttribute("uuid", Guid.NewGuid().ToString())
);
XElement slidesArray = new XElement("array", new XAttribute("rvXMLIvarName", "slides"));
group.Add(slidesArray);
foreach (string slideText in sec.Slides)
{
// ⬇️ 自動分頁:可能回傳 1..N 張投影片
foreach (XElement pageSlide in BuildSlidesAutoPaginate(song, sec.Name, slideText, width, height))
{
slidesArray.Add(pageSlide);
}
}
groupsArray.Add(group);
}
// Arrangements (empty)
root.Add(new XElement("array", new XAttribute("rvXMLIvarName", "arrangements")));
return new XDocument(new XDeclaration("1.0", "utf-8", null), root);
}
// ---------------- Slides & Pagination ----------------
static XElement BuildIntroGroup(Song song, int width, int height)
{
XElement group = new XElement("RVSlideGrouping",
new XAttribute("name", "Intro"),
new XAttribute("color", "1 1 1 0"),
new XAttribute("uuid", Guid.NewGuid().ToString())
);
XElement slidesArray = new XElement("array", new XAttribute("rvXMLIvarName", "slides"));
group.Add(slidesArray);
slidesArray.Add(BuildIntroSlide(song, width, height));
return group;
}
static XElement BuildIntroSlide(Song song, int docWidth, int docHeight)
{
XElement slide = NewEmptySlide();
XElement elements = new XElement("array", new XAttribute("rvXMLIvarName", "displayElements"));
string titleZh = song.TitleZh ?? song.Title;
string? titleEn = song.TitleEn;
int marginX = 80;
int width = docWidth - marginX * 2;
int gap = 20;
// 150pt / 105pt → RTF \fs (half-points)
int fsCn = 300;
int fsEn = 210;
int cnHeight = 260;
int enHeight = 180;
if (!string.IsNullOrWhiteSpace(titleEn))
{
int totalH = cnHeight + gap + enHeight;
int startY = (docHeight - totalH) / 2;
string cnRect = "{" + marginX + " " + startY + " 0 " + width + " " + cnHeight + "}";
elements.Add(BuildTextElement(titleZh, cnRect, fsCn, TextAlign.Center));
string enRect = "{" + marginX + " " + (startY + cnHeight + gap) + " 0 " + width + " " + enHeight + "}";
elements.Add(BuildTextElement(titleEn, enRect, fsEn, TextAlign.Center));
}
else
{
int startY = (docHeight - cnHeight) / 2;
string rect = "{" + marginX + " " + startY + " 0 " + width + " " + cnHeight + "}";
elements.Add(BuildTextElement(titleZh, rect, fsCn, TextAlign.Center));
}
slide.Add(elements);
return slide;
}
// 自動分頁:把一段 slideText 依版面自動切成多張投影片
static IEnumerable<XElement> BuildSlidesAutoPaginate(Song song, string sectionName, string slideText, int docWidth, int docHeight)
{
// 版面參數(與單頁版一致)
int marginX = 80;
int marginTop = 120;
int marginBottom = 120;
int innerGap = 12; // 同組中英行距
int width = docWidth - marginX * 2;
// 字級half-points100pt=200、90pt=180
int fsCn = 200;
int fsEn = 180;
// 高度估值(像素)
int cnHeight = 190;
int enHeight = 160;
// 一組(中+英)所需高度(含一點額外間距)
int pairSlot = cnHeight + innerGap + enHeight + 20;
// 可用高度
int usableHeight = docHeight - marginTop - marginBottom;
// 先做中英配對
List<string> rawLines = (slideText ?? "")
.Replace("\r\n", "\n").Replace("\r", "\n")
.Split('\n')
.Select(s => s.TrimEnd())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
List<Tuple<string?, string?>> pairs = new List<Tuple<string?, string?>>();
for (int i = 0; i < rawLines.Count;)
{
string line = rawLines[i];
if (ContainsCjk(line))
{
string? en = null;
if (i + 1 < rawLines.Count && !ContainsCjk(rawLines[i + 1]))
{
en = rawLines[i + 1];
i += 2;
}
else
{
i += 1;
}
pairs.Add(new Tuple<string?, string?>(line, en));
}
else
{
pairs.Add(new Tuple<string?, string?>(null, line));
i += 1;
}
}
// 開始分頁
int idx = 0;
while (idx < pairs.Count)
{
XElement slide = NewEmptySlide();
XElement elementsArray = new XElement("array", new XAttribute("rvXMLIvarName", "displayElements"));
int y = marginTop;
while (idx < pairs.Count)
{
string? cn = pairs[idx].Item1;
string? en = pairs[idx].Item2;
bool isPair = (cn != null && en != null);
int needed = isPair ? pairSlot : (ContainsCjk(cn ?? en ?? "") ? (cnHeight + 20) : (enHeight + 20));
// 如果放不下,先結束這一頁
if (y + needed > marginTop + usableHeight)
{
// 若這頁還沒放任何元素,強制塞一個以避免死循環
if (y == marginTop)
{
if (isPair)
{
string cnRect0 = "{" + marginX + " " + y + " 0 " + width + " " + cnHeight + "}";
elementsArray.Add(BuildTextElement(cn!, cnRect0, fsCn, TextAlign.Left));
string enRect0 = "{" + marginX + " " + (y + cnHeight + innerGap) + " 0 " + width + " " + enHeight + "}";
elementsArray.Add(BuildTextElement(en!, enRect0, fsEn, TextAlign.Left));
y += needed;
}
else
{
bool isCn = ContainsCjk(cn ?? en ?? "");
int fs = isCn ? fsCn : fsEn;
int boxH = isCn ? cnHeight : enHeight;
string rect0 = "{" + marginX + " " + y + " 0 " + width + " " + boxH + "}";
elementsArray.Add(BuildTextElement(cn ?? en ?? "", rect0, fs, TextAlign.Left));
y += (boxH + 20);
}
idx++; // 放了一個,移到下一筆
}
break; // 結束這頁
}
// 放進這頁
if (isPair)
{
string cnRect = "{" + marginX + " " + y + " 0 " + width + " " + cnHeight + "}";
elementsArray.Add(BuildTextElement(cn!, cnRect, fsCn, TextAlign.Left));
string enRect = "{" + marginX + " " + (y + cnHeight + innerGap) + " 0 " + width + " " + enHeight + "}";
elementsArray.Add(BuildTextElement(en!, enRect, fsEn, TextAlign.Left));
y += pairSlot;
}
else
{
bool isCn = ContainsCjk(cn ?? en ?? "");
int fs = isCn ? fsCn : fsEn;
int boxH = isCn ? cnHeight : enHeight;
string rect = "{" + marginX + " " + y + " 0 " + width + " " + boxH + "}";
elementsArray.Add(BuildTextElement(cn ?? en ?? "", rect, fs, TextAlign.Left));
y += (boxH + 20);
}
idx++;
}
// 加入右下角頁尾
string footerText = song.FooterText(sectionName);
elementsArray.Add(BuildTextElement(
footerText,
rect: FooterRect(docWidth, docHeight),
fontSizeHalfPoints: 28, // ≈14pt
align: TextAlign.Right
));
slide.Add(elementsArray);
yield return slide;
}
}
static XElement NewEmptySlide()
{
return new XElement("RVDisplaySlide",
new XAttribute("backgroundColor", "1 1 1 0"),
new XAttribute("highlightColor", ""),
new XAttribute("drawingBackgroundColor", "false"),
new XAttribute("enabled", "true"),
new XAttribute("hotKey", ""),
new XAttribute("label", ""),
new XAttribute("notes", ""),
new XAttribute("UUID", Guid.NewGuid().ToString()),
new XAttribute("chordChartPath", ""),
new XElement("array", new XAttribute("rvXMLIvarName", "cues"))
);
}
enum TextAlign { Left, Center, Right }
static string FooterRect(int docWidth, int docHeight)
{
int pad = 30;
int footerWidth = 900; // 雙語標題較長,留寬一點
int footerHeight = 50;
int x = docWidth - pad - footerWidth;
int y = docHeight - pad - footerHeight;
return "{" + x + " " + y + " 0 " + footerWidth + " " + footerHeight + "}";
}
// 建立一個 RVTextElementRTF 內用白色、Unicode \uNNNN、可選對齊
static XElement BuildTextElement(string text, string rect, int fontSizeHalfPoints, TextAlign align)
{
string rtf = BuildRtf(text, fontSizeHalfPoints, align);
string rtfB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(rtf));
string plainB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(text ?? ""));
return new XElement("RVTextElement",
new XAttribute("displayName", "Text"),
new XAttribute("UUID", Guid.NewGuid().ToString()),
new XAttribute("typeID", "0"),
new XAttribute("displayDelay", "0"),
new XAttribute("locked", "false"),
new XAttribute("persistent", "0"),
new XAttribute("fromTemplate", "false"),
new XAttribute("opacity", "1"),
new XAttribute("source", ""),
new XAttribute("bezelRadius", "0"),
new XAttribute("rotation", "0"),
new XAttribute("drawingFill", "false"),
new XAttribute("drawingShadow", "false"),
new XAttribute("drawingStroke", "false"),
new XAttribute("fillColor", "1 1 1 1"),
new XAttribute("adjustsHeightToFit", "false"),
new XAttribute("verticalAlignment", "0"),
new XAttribute("revealType", "0"),
new XElement("RVRect3D",
new XAttribute("rvXMLIvarName", "position"),
rect
),
new XElement("dictionary", new XAttribute("rvXMLIvarName", "stroke"),
new XElement("NSColor",
new XAttribute("rvXMLIvarName", "RVShapeElementStrokeColorKey"),
"1 1 1 1"
),
new XElement("NSNumber",
new XAttribute("rvXMLIvarName", "RVShapeElementStrokeWidthKey"),
new XAttribute("serialization", "double"),
"0"
)
),
new XElement("NSString", new XAttribute("rvXMLIvarName", "PlainText"), plainB64),
new XElement("NSString", new XAttribute("rvXMLIvarName", "RTFData"), rtfB64)
);
}
// 產生白字 + Unicode 的 RTF全 ASCII非 ASCII 用 \uNNNN
static string BuildRtf(string text, int fontSizeHalfPoints, TextAlign align)
{
// 將換行先標準化,之後轉成 \line
string normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
string content = ToRtfUnicode(normalized).Replace("\n", @"\line ");
string alignCode =
align == TextAlign.Right ? @"\qr " :
(align == TextAlign.Center ? @"\qc " : @"\ql ");
// 注意:\fs 是 half-points\uc0 表示 \u 後不帶替代字元
// 字型表f0=Arial備用西文字型、f1=Microsoft JhengHeiCJK 友善)
return @"{\rtf1\ansi\deff0"
+ @"{\fonttbl{\f0 Arial;}{\f1 Microsoft JhengHei;}}"
+ @"{\colortbl ;\red255\green255\blue255;}"
+ @"\uc0\f1\fs" + fontSizeHalfPoints + " " + alignCode + @"\cf1 "
+ content
+ "}";
}
// 把所有非 ASCII 字元轉成 RTF Unicode\uNNNN以 16 位有號整數輸出),並處理 RTF 轉義
static string ToRtfUnicode(string s)
{
StringBuilder sb = new StringBuilder(s.Length * 6);
foreach (char ch in s)
{
switch (ch)
{
case '\\': sb.Append(@"\\"); break;
case '{': sb.Append(@"\{"); break;
case '}': sb.Append(@"\}"); break;
case '\n': sb.Append('\n'); break; // 之後替換成 \line
default:
if (ch <= 0x7F)
{
sb.Append(ch);
}
else
{
// \uNNNN 使用 16 位帶正負號的數值
sb.Append(@"\u").Append(unchecked((short)ch)).Append(' ');
}
break;
}
}
return sb.ToString();
}
}
}