Excel ขึ้นข้อความ "We found a problem with some content" บน XLSX ที่ LibreOffice กับ reader ทำเองทั้งหลายเปิดได้โดยไม่มีปัญหาอะไร เพราะ Excel บังคับสองอย่างที่ reader เหล่านั้นมองข้าม: attribute ที่ schema กำหนดว่าจำเป็น และกฎเรื่องความไม่ซ้ำของ Open Packaging Conventions HotXLS ซึ่งเป็นคอมโพเนนต์สเปรดชีต Excel แบบเนทีฟสำหรับ Delphi และ C++Builder เจอเข้าเต็ม ๆ แบบนั้นใน v2.382.5 ครั้งแรกที่ output ของมันผ่าน Excel COM instance จริง ๆ โดยมีสามสาเหตุคือ <phoneticPr> ที่ไม่มี fontId, Override ที่ซ้ำกันใน [Content_Types].xml และ root relationship สองตัวที่ใช้ rId4 ร่วมกัน
ทำไม Excel ถึงปฏิเสธ package ที่ reader ตัวอื่นทุกตัวเปิดได้
เพราะกล่องซ่อมไฟล์นั้นเป็นตัว validate ทั้ง schema และ package ไม่ใช่ parser ล้ม corpus ของ HotXLS round-trip เทมเพลตสินเชื่อ 4805 สูตรผ่านไลบรารี, ผ่าน LibreOffice และผ่าน XML validator ในชุดเทสต์มาหลายสัปดาห์ ไฟล์ที่ save ออกมานั้นสมบูรณ์เชิงโครงสร้างในความหมายแบบ OPC ที่ใช้ในบทความเรื่องการ resolve relationship แบบ OPC ของ XLSX: ทุก part เข้าถึงได้ ทุก target resolve ได้ จากนั้นก็มีเครื่อง Windows ที่มี Excel 16.0 build 20326 ใช้ได้ corpus runner เปิดเทมเพลตที่ save ไว้ผ่าน Workbooks.Open ใน COM instance ที่แยกออกมาพร้อม DisplayAlerts ปิดอยู่ แล้วการเรียกก็ล้มไปเลย แบบอินเทอร์แอกทีฟไฟล์เดียวกันก็ขึ้นไดอะล็อกที่คุ้นเคยว่าถามว่าจะซ่อมไหม และ repair log ในกรณีที่ Excel ยอมเขียนออกมา ก็บอกชื่อ part แต่ไม่บอกกฎ bug สามตัวที่แยกกันซ่อนอยู่หลังกล่องข้อความเดียว และ Excel ไม่รายงานทีละตัว มันปฏิเสธทั้งเวิร์กบุ๊กแล้วปล่อยให้คุณหาและวิเคราะห์เอง ต่อจากนี้คือแต่ละกฎ, บรรทัดใน HotXLS ที่ละเมิดมัน และการแก้ที่ออกไป เพราะทุกข้อล้วนเป็นกฎที่ writer XLSX ใน Delphi ตัวไหนก็สะดุดได้
กฎข้อ 1: phoneticPr ต้องมี fontId แม้ค่าจะเป็นศูนย์
element <phoneticPr> พก attribute fontId ที่ประกาศไว้ use="required" ใน ECMA-376 Part 1 §18.4.3 และค่า 0 ก็เป็นดัชนีฟอนต์ที่ถูกกฎหมาย ไม่ได้หมายความว่าไม่มีค่าตัวนี้ writer เวิร์กชีตของ HotXLS ตัวเก่ามองศูนย์ว่า "ไม่ได้ตั้ง" แล้ว emit attribute นั้นเฉพาะเมื่อ Sheet.PhoneticFontId > 0 นั่นเป็นปฏิกิริยาตามสัญชาตญาณของคนเขียน Delphi เพราะฟิลด์จำนวนเต็มมีค่าเริ่มต้นเป็นศูนย์ แต่มันสร้าง <phoneticPr type="noConversion"/> ให้กับเวิร์กบุ๊กใดก็ตามที่ฟอนต์ phonetic บังเอิญเป็นฟอนต์ตัวแรกใน styles.xml ซึ่งก็คือสิ่งที่เทมเพลตสินเชื่อใน corpus ของ HotXLS มีอยู่เป๊ะ ๆ Excel จึงปฏิเสธขาเข้ากับค่าที่ตัวมันเองเป็นคนเขียนออกมา
// lxHandleX.pas, writer ของเวิร์กชีต — ก่อน v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// v2.382.5 — attribute นี้บังคับ รวมทั้งค่า zero ด้วย
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS ยัง emit element นี้เฉพาะเมื่อ TXLSXWorksheet.PhoneticType ไม่ว่าง เวิร์กบุ๊กที่ไม่เคยพกการตั้งค่า phonetic จึงไม่ได้รับผลกระทบ เทสต์ regression PhoneticSettings_DefaultFontIsExplicit ตั้ง PhoneticFontId เป็นศูนย์บนชีตใหม่ แล้ว save และ assert ว่ามี <phoneticPr fontId="0" อยู่ใน xl/worksheets/sheet1.xml บทเรียนที่กว้างกว่านั้นคือ "ละไว้เมื่อเป็นค่า default" ปลอดภัยเฉพาะเมื่อ schema ประกาศ default ไว้ type กับ alignment มี default ใน element นั้น แต่ fontId ไม่มี
กฎข้อ 2: หนึ่ง Override ต่อหนึ่งชื่อ part ใน [Content_Types].xml
content types stream ประกาศชื่อ part แต่ละชื่อได้มากที่สุดครั้งเดียว และ Excel มอง Override ตัวที่สองสำหรับ PartName เดียวกันว่าเป็นความเสียหาย แม้ทั้งสอง entry จะพก ContentType เหมือนกันก็ตาม HotXLS มี writer สองตัวที่ป้อน stream นี้ BuildContentTypesXml ประกาศทุก part ที่ object model สร้าง: workbook, styles, shared strings, theme, worksheet และเมื่อ TXLSXWorkbook.CustomProperties.Count > 0 ก็จะประกาศ /docProps/custom.xml ด้วย เมื่อเปิด PreserveUnsupportedParts อยู่ TXLSXOpaquePackage ก็จะต่อ Override ให้ทุก part ที่มันเก็บมาแบบ verbatim จาก source package เพื่อให้ byte เหล่านั้นยังถูกประกาศตอนเขียนออก การชนกันเกิดกับ part ที่อยู่ทั้งสองฝั่ง custom document property ถูก parse เข้า model แต่ docProps/custom.xml ของ source package ก็ถูกเก็บแบบ opaque ด้วย stream ที่ merge แล้วจึงประกาศมันสองครั้ง และ chart กับ pivot cache part ก็ลงเอยในจุดเดียวกันได้เมื่อ model สร้าง part ที่ opaque layer เก็บไว้แล้วขึ้นมาใหม่ ก่อน v2.382.5 ตัว ContentTypeOverridesXml ไม่มีทางเห็นว่า model เขียนอะไรไปแล้ว จึงไม่มีทางรู้
<!-- สิ่งที่ Excel เห็นก่อน v2.382.5 -->
<Override PartName="/docProps/custom.xml"
ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
การแก้ส่ง XML ที่สร้างขึ้นเข้าไปใน ContentTypeOverridesXml แล้วให้ opaque writer parse มันก่อนที่จะ emit อะไรออกมา มีสองรายละเอียดที่พาความถูกต้องมา OpcLowerPartName แปลงเป็นตัวพิมพ์เล็ก, เปลี่ยน backslash เป็น forward slash และตัด slash นำหน้าออกก่อนเทียบ เพราะชื่อ part ของ OPC ถูกเทียบแบบไม่สนตัวพิมพ์ และ model เขียนชื่อเหล่านั้นพร้อม slash นำหน้า ขณะที่ opaque layer เก็บชื่อ item ใน ZIP โดยไม่มี และผู้เรียกใน BuildContentTypesXml ส่ง Result + '</Types>' ซึ่งปิดเอกสารที่สร้างค้างไว้ เพื่อให้ TXMLReader เห็น input ที่ well-formed ไม่ใช่ stream ที่ถูกตัดขาด กฎที่โผล่ขึ้นมาคือใครมาก่อนชนะโดยให้ model อยู่หน้า: อะไรก็ตามที่ object model ประกาศถือเป็นข้อยุติ และ opaque replay แค่เติมช่องว่าง
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// parse stream ที่ model สร้าง แล้วเก็บ PartName ทุกตัวที่ถูกประกาศ
while Reader.Read do
if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
begin
Index:= Reader.AttributeIndex('PartName');
if Index>= 0 then
UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
end;
for i:= 0 to FParts.Count- 1 do
begin
Part:= TXLSXOpaquePart(FParts[i]);
if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
(UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
Continue; // ถูกประกาศไปแล้ว หรือเป็น part ของ rels
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
กฎข้อ 3: relationship Id ไม่ซ้ำกันภายใน relationships part หนึ่ง
ทุก Relationship ใน part .rels ต้องมี Id ที่ไม่ซ้ำกันภายใน part นั้น และ Excel ปฏิเสธ package เมื่อมีสองตัวใช้ร่วมกัน HotXLS เขียน _rels/.rels ระดับ package ด้วย identifier แบบตายตัว: rId1 สำหรับ workbook, rId2 และ rId3 สำหรับ core และ extended document property และ rId4 สำหรับ custom property เมื่อ model มี จากนั้น opaque package ก็ต่อ root relationship อะไรก็ตามที่มันเก็บมาจาก source โดย renumber identifier ใดที่อยู่ในลิสต์ UsedIds แล้ว ลิสต์นั้นรู้จัก rId1 ถึง rId3 แต่มันไม่รู้จัก rId4 และไม่รู้ว่า model กำลังจะ emit relationship ของ custom property ของตัวเอง ดังนั้น source package ที่ relationship custom property ของมันเป็น rId4 ด้วย ซึ่งก็คือค่าที่ Excel เขียนโดยดีฟอลต์ ก็ออกมาพร้อม entry rId4 สองตัวที่ชี้ไปยัง target เดียวกัน ผู้เรียกอย่าง BuildRootRelsXml ตอนนี้ส่ง Workbook.FCustomProps.Count > 0 เป็นอาร์กิวเมนต์ตัวที่สอง การจองและการข้ามจึงขับเคลื่อนด้วยเงื่อนไขเดียวกับที่ตัดสินว่า model จะ emit rId4 หรือไม่เลย การ renumber ที่รากของ package ปลอดภัยเพราะไม่มีอะไรในเวิร์กบุ๊กอ้าง identifier ของ root relationship ด้วยชื่อ แต่เคล็ดเดียวกันนี้จะผิดถ้าลงไปหนึ่งระดับ ซึ่ง attribute r:id ใน workbook.xml ผูกกับ identifier ใน relationships part ของเวิร์กบุ๊ก นั่นคือเหตุผลที่ MergeWorkbookRelationshipsXml เก็บ map ของ identifier แยกต่างหาก
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // ถูกจองโดย writer ของ model
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// ตอนนี้ model เป็นเจ้าของ custom property แล้ว อย่า replay สำเนาจาก source
if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
Continue;
Id:= Rel.Id;
if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
Id:= AllocateRelationshipId(UsedIds); // rIdN ที่ว่างต่ำสุด
UsedIds.Add(String(Id));
...
end;
ความล้มเหลวสามอย่างนี้มีอะไรเหมือนกัน
ทั้งสามอย่างเป็นอาการของ writer ที่มีสองแหล่งข้อมูลและไม่มีเจ้าของ invariant ของ package เพียงคนเดียว object model สร้าง part ที่มันเข้าใจ ส่วน opaque layer replay part ที่มันไม่เข้าใจ เพื่อให้การ round trip เก็บแผนภูมิ, pivot cache, custom XML และทุกอย่างที่เหลือที่อธิบายไว้ในบันทึกเรื่องการ round-trip แบบ lossless ของ theme, extLst และ calcChain ไว้ได้ แต่ละฝั่งสอดคล้องกันเองได้ ข้อบังคับที่ OPC วางไว้กับทั้ง package คือชื่อ part ของ Override ที่ไม่ซ้ำและ relationship identifier ที่ไม่ซ้ำต่อ part ซึ่งมีอยู่เฉพาะตรงรอยต่อที่ทั้งสองถูกนำมาต่อกัน และจนถึง v2.382.5 ก็ไม่มีใครตรวจรอยต่อนั้น bug fontId ก็รูปร่างเดียวกันในอีกระดับ: writer รู้ว่ามันอยากละอะไร แต่ไม่เคยไปปรึกษา schema ที่บอกว่าละไม่ได้ การแก้ที่ HotXLS ลงเอยด้วยคือลำดับความสำคัญแบบตายตัว ไม่ใช่ heuristic การ merge model เขียนก่อน opaque layer เห็นสิ่งที่เขียนไปแล้วและยอมถอยเมื่อชนกัน และ corpus runner ตอนนี้บังคับ invariant จากภายนอกด้วย verify_opc_uniqueness ซึ่งอ่าน [Content_Types].xml และ item .rels ทุกตัวใน package ที่ save แล้ว และทำให้เคส fail เมื่อเจอ PartName, Extension หรือ Id ซ้ำ การตรวจนั้นถูก ไม่ต้องใช้ Excel และจะจับ bug ได้สองในสามตัวตั้งแต่รัน corpus ครั้งแรก
ชุดเดียวกัน: print area ที่เป็นสูตร ไม่ใช่ range
รอบตรวจด้วย Excel ยังติดธงที่ _xlnm.Print_Area ของเทมเพลตสินเชื่อด้วย ซึ่ง Excel รายงานเป็น $A$1:$J$29 บนต้นฉบับ และต้องรายงานเหมือนกันเป๊ะบนสำเนาที่ save แล้ว มี bug สองตัวแยกกันอยู่หลัง assertion เดียวนั้น ตอน import ตัว XlsxStripSheetPrefix ตัดทุกอย่างจนถึง ! ตัวแรกที่ไม่ได้อยู่ในเครื่องหมายคำพูด ดังนั้น print area แบบไดนามิกอย่าง OFFSET('Print Data'!$A$1,0,0,2,2) จึงกลับมาเป็น $A$1,0,0,2,2) และ union ที่มีชื่อชีตกำกับอย่าง 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 ก็เสีย prefix ไปแค่ segment แรก ส่วนตอน export writer เติมชื่อชีตครั้งเดียวให้ PrintArea ทั้งก้อนที่เก็บไว้ ดังนั้น union ธรรมดาอย่าง $A$1:$B$2,$D$1:$E$2 จึงทิ้งให้ไลบรารีมี segment แรกที่กำกับชื่อชีตและ segment ที่สองที่เปลือย ซึ่ง Excel ไม่ยอมรับว่าเป็นนิยาม _xlnm.Print_Area ตาม ECMA-376 Part 1 §18.2.5
// ตอน import: ตัด prefix เฉพาะเมื่อสิ่งที่เหลือเป็น sqref ธรรมดา
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
Result:= Formula;
if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
Exit;
Area:= Copy(Formula, Start, Length(Formula));
if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
Result:= Area; // 'Sheet'!$A$1:$J$29 -> $A$1:$J$29
end; // OFFSET(...) ถูกคืนกลับไปโดยไม่แตะ
// ตอน export: กำกับชื่อชีตให้ทุก segment ที่คั่นด้วยจุลภาค หรือไม่ก็ไม่ให้เลย
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
Result:= Area;
... split Area on ',' with StrictDelimiter ...
for I:= 0 to Parts.Count- 1 do
if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
Exit; // เป็นสูตร: emit แบบ verbatim
Result:= '';
for I:= 0 to Parts.Count- 1 do
begin
if I> 0 then Result:= Result+ ',';
Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
end;
end;
กฎการจับคู่เหมือนกันทั้งสองฝั่ง: print area เป็น range เปล่า ๆ ก็ต่อเมื่อทุก segment parse ได้ว่าเป็น range ไม่งั้นมันคือสูตรและเดินทางแบบ verbatim PrintArea_FormulaDefinitionSurvivesRoundTrip ครอบคลุมทั้งฐานที่ตั้งชื่อไว้, ฐานที่กำกับชื่อชีต และ union ผ่านสองรอบ save-and-reopen ส่วน print area ทำงานร่วมกับ page setup และโมเดลการพิมพ์ที่เหลืออย่างไรมีอยู่ในบทความเรื่องการป้องกันชีต, page setup และการพิมพ์
จะหาว่า Excel กำลังคัดค้านกฎข้อไหนได้อย่างไร
เริ่มจากสมมติฐานว่าตัว validator ของคุณเองผิด เพราะมันผ่าน เทสต์ validator ของ Open XML SDK จะบอกชื่อ schema violation อย่าง fontId ที่หายไปพร้อม part และ XPath และชั้น packaging ที่อยู่ใต้มันก็ปฏิเสธที่จะเปิด package ที่มี content-type entry ซ้ำกันเลย ดังนั้นรันมันก่อนอย่างอื่น ถ้ามันเงียบและ Excel ยังซ่อมอยู่ ให้ bisect package: แตก zip, ลบ part หนึ่งตัวพร้อม relationship และ Override ของมัน, zip กลับ แล้วเปิดใหม่ ทำแบบนี้ลด candidate ลงครึ่งหนึ่งทุกครั้งจนกล่องข้อความหายไป bug สามตัวตรงนี้โผล่ออกมาในลำดับนั้น และไม่มีตัวไหนที่จะมองเห็นได้ในไฟล์ที่ซ่อมแล้วซึ่ง Excel เสนอให้ save เพราะการซ่อมจะทิ้งหรือ renumber entry ที่มีปัญหาไปเงียบ ๆ ขอบเขตของการแก้ใน v2.382.5 ก็คุ้มที่จะบอกให้ชัดเหมือนกัน การ dedupe เป็นแบบใครมาก่อนชนะโดยให้ model อยู่หน้า ดังนั้นถ้า source package ประกาศ content type ที่ต่างออกไปสำหรับ part ที่ model สร้างด้วย คำประกาศของ model จะชนะและของ source จะถูกทิ้ง ซึ่งถูกต้องสำหรับ part ที่ HotXLS สร้างใหม่ และไม่ใช่การ merge แบบทั่วไป verify_opc_uniqueness ตรวจแค่ความไม่ซ้ำ มันไม่ได้ validate schema ดังนั้น required attribute ตัวใหม่ในอนาคตก็ยังต้องพึ่ง Excel หรือ schema validator ในการเปิดเผย และการเดิน TXMLReader เพิ่มอีกรอบบน content types stream ที่สร้างขึ้นก็รันทุกครั้งที่ save โดยเปิด PreserveUnsupportedParts ไว้ ซึ่งเป็นต้นทุนเล็ก ๆ เทียบกับ stream ที่แทบไม่เคยเกินไม่กี่กิโลไบต์ พอมีครบทั้งสามอย่างนี้ ทั้งบิลด์ Win32 และ Win64 ของเทมเพลตสินเชื่อก็เปิดใน Excel ได้โดยไม่มีกล่องข้อความ, คำนวณสูตรที่ผ่านการตรวจทั้ง 4805 ตัวใหม่โดยไม่มี mismatch และรายงาน print area ได้เหมือนต้นฉบับ
ถ้าคุณเขียน XLSX จาก Delphi เอง checklist นั้นสั้น: emit ทุก attribute ที่ schema ระบุว่าจำเป็นไม่ว่าค่าจะเป็นอะไร, ประกาศชื่อ part แต่ละชื่อครั้งเดียว และเก็บลิสต์ identifier ที่ใช้แล้วหนึ่งลิสต์ต่อ relationships part ให้ครบทุก writer ที่แตะมัน ถ้าคุณอยากให้ลิสต์นั้นมีอยู่แล้วและถูกทดสอบกับ Excel ไม่ใช่แค่กับ reader ของคุณเอง ตัว writer ของ package ที่อธิบายตรงนี้ก็อยู่ในคอมโพเนนต์สเปรดชีต Delphi ของ HotXLS พร้อมกับการ round-trip ของ opaque part ที่ทำให้รอยต่อนั้นคุ้มที่จะเฝ้าไว้ตั้งแต่แรก