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 Slides { get; set; } = new List(); } class Song { public string Title { get; set; } = "Untitled"; // 後備 public string? TitleZh { get; set; } // 中文歌名(若有) public string? TitleEn { get; set; } // 英文歌名(若有) public List
Sections { get; set; } = new List
(); 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
sections = new List
(); Section? current = null; List slideBuffer = new List(); 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 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-points):100pt=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 rawLines = (slideText ?? "") .Replace("\r\n", "\n").Replace("\r", "\n") .Split('\n') .Select(s => s.TrimEnd()) .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList(); List> pairs = new List>(); 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(line, en)); } else { pairs.Add(new Tuple(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 + "}"; } // 建立一個 RVTextElement(RTF 內用白色、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 JhengHei(CJK 友善) 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(); } } }