HotXLS Delphi Component จะ replay แผนภูมิ Excel ที่ไม่ถูกแก้ไขแบบ byte ต่อ byte ได้ก็ต่อเมื่อมีสองเงื่อนไขพร้อมกัน: แผนภูมินั้นถูกเข้าถึงผ่าน relationship ของ drawing ในเวิร์กชีต ไม่ใช่ผ่านชื่อ part ที่เดาขึ้นมา และ fingerprint ของ model ขนาด 64 บิตถูกเก็บหลังจากที่ model ของแผนภูมิ parse เสร็จแล้ว เวอร์ชัน 2.382.0 แก้เงื่อนไขแรก ส่วนเวอร์ชัน 2.382.3 แก้เงื่อนไขที่สองและเริ่ม round-trip ค่า anchor offset xdr:colOff กับ xdr:rowOff ที่ไม่เป็นศูนย์ ซึ่งก่อนหน้านี้ writer ของ drawing hard-code ให้เป็นศูนย์เสมอ bug ทั้งสองตัวโผล่มาจากเคสใน corpus ท้องถิ่นเพียงเคสเดียวคือ two-charts.xlsx: อย่างแรก structural assertion เห็นว่า chart part สองตัวกลายเป็นสามตัว จากนั้นการเทียบ byte ของทุกไฟล์ xl/charts/chartN.xml ก็เผยว่าแผนภูมิที่ไม่มีใครแตะยังถูกเขียนใหม่อยู่ — และไม่มีปัญหาตัวไหนโยน exception หรือทำให้ Excel บ่นเลย นั่นคือเหตุผลที่มันอยู่รอดมานานขนาดนั้น
ทำไมเวิร์กบุ๊กสองแผนภูมิถึงกลับมาพร้อม chart part สามตัว
เพราะ loader มี fallback ที่เดาเอา เมื่อเวิร์กชีตหนึ่งไม่มี relationship ของ drawing ใน part .rels ของมัน โค้ดเก่าก็สมมติว่า drawing อยู่ที่ชื่อตามธรรมเนียม xl/drawings/drawing{i+1}.xml โดย i คือตำแหน่งของชีต แล้วแนบ part นั้นถ้ามันมีอยู่จริงใน archive ใน two-charts.xlsx ชีตแรกไม่มี drawing และไม่มี part .rels เลย ขณะที่ xl/drawings/drawing1.xml มีอยู่จริง — มันเป็นของชีตที่สอง ซึ่งเข้าถึงมันผ่าน Target="../drawings/drawing1.xml" ชีต 1 จึงได้แผนภูมิที่มันไม่เคยอ้างอิงมาครอบครอง chart1.xml ถูก parse สองครั้ง และตอน save เวิร์กบุ๊กก็ถูกเขียนออกมาพร้อม chart part สามตัวแทนที่จะเป็นสอง
การแก้ใน HotXLS v2.382.0 ตัดการเดาออกไปทั้งหมด ตอนนี้ drawing ของเวิร์กชีตถูกโหลดผ่าน ParPartTargets[i].Values[XlsxRtDrawing] เท่านั้น ซึ่งเป็น target ที่บันทึกไว้สำหรับ relationship type ของ drawing บนชีตนั้น และชีตที่ไม่มี relationship แบบนั้นก็จะไม่ได้ drawing เลย นั่นคือพฤติกรรมที่ฟอร์แมตกำหนด: element <drawing r:id="…"/> ในเวิร์กชีต (ECMA-376 Part 1 §18.3.1.36) เป็นจุดเชื่อมเดียวระหว่างชีตกับ drawing ของมัน และชื่อ part ใน package แบบ OPC ก็ไม่มีความหมายอะไรนอกเหนือจากที่ relationship graph กำหนดให้ ไฟล์ที่ Excel เขียนบังเอิญใช้ชื่อตามธรรมเนียม นั่นคือสิ่งที่ทำให้ทางลัดนี้ผ่านมาได้นานขนาดนี้ ส่วนคำอธิบายแบบเดินทีละขั้นของ การ resolve relationship แบบ OPC ใน HotXLS ครอบคลุมเหตุผลที่การเดาชื่อ part ไม่ปลอดภัยเลยแม้การเดาจะถูกเกือบทุกครั้ง
// ก่อน v2.382.0: relationship ของ drawing ที่หายไปจะ fallback ไปเดา
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
LoadDrawing(zip, drawingName); // อาจเป็นของชีตอื่น
// ตั้งแต่ v2.382.0: relationship หรือไม่ก็ไม่โหลดเลย
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
LoadDrawing(zip, drawingName);
fingerprint ของแผนภูมิรับประกันอะไร
fingerprint ตัดสินใจเป็นรายแผนภูมิว่า save จะคัดลอก part ต้นฉบับได้หรือต้องสร้างใหม่ ตอน import ถ้าเปิด PreserveUnsupportedParts ไว้ก่อน Open HotXLS จะเก็บ byte UTF-8 ดิบของ chart part แต่ละตัวไว้ใน FRawChartXml สร้าง serialization ของ typed model เองด้วย BuildChartKnownXml แล้วเก็บความยาวของ serialization นั้นไว้ใน FRawChartModelLength และ hash ไว้ใน FRawChartModelHash hash นั้นคือ FNV-1a ที่คำนวณบน UTF-16 code unit ของ XML ที่สร้างขึ้น โดยใช้ offset basis มาตรฐานขนาด 64 บิต 14695981039346656037 และ prime 1099511628211 ตอน save XlsxChartRawModelUnchanged จะสร้าง known XML ใหม่และเทียบทั้งความยาวและ hash ถ้าตรงกันก็หมายความว่า typed model เหมือนตอน import เป๊ะ ดังนั้นไม่มีอะไรที่แอปพลิเคชันจะเปลี่ยนได้ถูกเปลี่ยนไปแล้ว
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
const KnownXml: WideString): Boolean;
begin
Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
(Length(KnownXml) = Chart.FRawChartModelLength) and
(XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;
function BuildChartXmlFromKnown(Chart: TXLSXChart;
const KnownXml: WideString): WideString;
begin
if Chart.FRawChartXml = '' then
Result := KnownXml // ไม่มีอะไรถูกเก็บไว้
else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
Result := XlsxDecodeChartUtf8(Chart.FRawChartXml) // replay แบบ verbatim
else
Result := XlsxMergeChartXml(
XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // merge เชิงโครงสร้าง
end;
writer ของ XLSX ไปไกลกว่า BuildChartXmlFromKnown อีกขั้น เมื่อ model ไม่เปลี่ยนและ StrictOOXML ปิดอยู่ มันจะลองคัดลอก compressed entry ตรง ๆ จาก archive ต้นทางไปยัง output ภายใต้ชื่อ part ใหม่ของแผนภูมิก่อนเลย ดังนั้น byte จึงไม่ต้องถูก decode แล้ว deflate ใหม่ด้วยซ้ำ ถ้าการคัดลอกนั้นทำไม่ได้จริง ๆ มันจึงตกไปที่เส้นทาง decode-or-merge กลไกเอง — ความยาวบวก hash, replay เมื่อเท่ากัน, merge เมื่อไม่เท่า — คือกลไกที่อธิบายไว้ในบันทึกเรื่องการแก้แผนภูมิ Excel โดยไม่ทำ ChartML หาย บทความนี้พูดถึงวิธีที่มันหยุดทำงานไปเงียบ ๆ ต่างหาก
ทำไมแผนภูมิทุกตัวถึงยังตกไปที่เส้นทาง merge อยู่ดี
เพราะ fingerprint ถูกเก็บเร็วเกินไปหนึ่ง call การ parse แผนภูมิใน HotXLS เป็น SAX pass บน chart part ตามด้วย recovery pass ชุดหนึ่งที่ดึงรายละเอียดออกจากข้อความดิบซึ่ง SAX handler ไม่ได้ model ไว้ตรง ๆ: XlsxChartParseSeriesFlags อ่าน block <c:ser> แต่ละตัวเพื่อเอา flag <c:smooth> กับค่า srgbClr ของ marker fill และ marker line จากนั้นก็กู้ axis crossing mode กับ style ของ major และ minor tick-mark สำหรับแกน category และ value กลับมา ก่อน v2.382.3 ลำดับตอนท้ายของ ParseChartXml คือ: classify กลุ่มแกน, สร้าง known XML, เก็บความยาวกับ hash, แล้วค่อยรัน XlsxChartParseSeriesFlags fingerprint จึงบรรยาย model ที่ยังขาด smooth flag, สี marker และ tick mark ตอน save BuildChartKnownXml รันบน model ที่เสร็จสมบูรณ์แล้ว ซึ่งตอนนี้ emit <c:smooth val="1"/> กับสี marker ที่กู้กลับมาได้ XML ยาวขึ้น, hash ต่างออกไป, XlsxChartRawModelUnchanged คืนค่า False และแผนภูมิก็ตกไปที่ XlsxMergeChartXml merge เป็น operation ที่ถูกต้องสำหรับแผนภูมิที่มีคนแก้ แต่มันไม่ใช่ operation ที่รักษา byte: มัน reserialize ต้นไม้ใหม่ และกฎเรื่อง ownership ที่ให้ typed model ชนะสำหรับ series, แกน และ plot group ก็หมายความว่า node ที่สร้างใหม่จะแทนที่ตัวเดิม ผลลัพธ์ที่มองเห็นได้ในรอบรัน corpus คือสี series ที่เพี้ยนไปบนแผนภูมิที่ไม่มีใครแก้ — ทุกแผนภูมิในทุกเวิร์กบุ๊กที่ preserve ไว้ ทุกครั้งที่ save โดยไม่มี diagnostic ที่ไหนเลย
การซ่อมคือการสลับลำดับครั้งเดียว: ตอนนี้ XlsxChartParseSeriesFlags รันก่อนที่ known XML จะถูกสร้าง ดังนั้น fingerprint จึงบรรยาย model อย่างที่มันจะเป็นตอนที่แอปพลิเคชันเห็นมันครั้งแรก บทเรียนนี้ใช้ได้กว้างกว่าแผนภูมิ fingerprint สำหรับตรวจจับการเปลี่ยนแปลงจะดีได้แค่เท่าจังหวะที่เก็บมัน และจังหวะที่ปลอดภัยคือหลังจากทุก pass ที่แก้ model ได้รันจบแล้ว HotXLS มีจุดเก็บค่าสองค่าเดียวกันอีกจุด คือ baseline ที่มันตั้งใหม่เทียบกับไฟล์ output หลังจาก save สำเร็จ และจุดนั้นก็รันบน model ที่ parse ครบแล้วมาตลอด มีแต่จุดตอน import ที่แปลกแยกออกไป
anchor offset หายไปไหน
หายไปในเลขศูนย์ตรง ๆ twoCellAnchor ใน drawing part ยึดแผนภูมิไว้ระหว่างสองเซลล์ และแต่ละมุมจะพกดัชนีเซลล์บวก offset ภายในเซลล์นั้น: from (ECMA-376 Part 1 §20.5.2.5) และ to (§20.5.2.32) ต่างก็มี col, colOff (§20.5.2.4), row และ rowOff offset เหล่านั้นอยู่ในหน่วย English Metric Unit โดย 914400 หน่วยต่อนิ้ว และ Excel จะเขียนค่าที่ไม่ใช่ศูนย์ทุกครั้งที่แผนภูมิถูกวางหรือย่อขยายด้วยเมาส์ ซึ่งก็คือแผนภูมิเกือบทั้งหมด แผนภูมิตัวแรกใน two-charts.xlsx เริ่มที่แถว 0 ด้วย rowOff เท่ากับ 19049 และจบที่คอลัมน์ 8 แถว 15 ด้วย colOff 247650 กับ rowOff 66674 — ราวหนึ่งส่วนสี่นิ้วเข้าไปในคอลัมน์สุดท้าย drawing parser ใน HotXLS อ่านค่าทั้งสี่นี้มาตลอด — โค้ดฝั่งรูปภาพใช้มันอยู่ — แต่ writer ของแผนภูมิ emit <xdr:colOff>0</xdr:colOff> และ <xdr:rowOff>0</xdr:rowOff> ให้ทุกมุม ทำให้แผนภูมิแต่ละตัวถูก snap เข้ากริดเซลล์ตอน save
// ตั้งแต่ v2.382.3 ตัวเขียน anchor replay offset แบบ EMU ที่ import มา
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
'<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
'<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
'<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...
ตอนนี้ TXLSXChart พก FFromColOff, FFromRowOff, FToColOff และ FToRowOff ซึ่งเติมมาจาก drawing parser และถูกคัดลอกไปพร้อม state ของ anchor ตัวอื่นเมื่อมีการ assign แผนภูมิ พวกมันเป็น private โดยตั้งใจ: พื้นผิว anchor แบบ public ยังเป็นพิกัดเซลล์สี่ตัวคือ FromRow, FromCol, ToRow และ ToCol และแผนภูมิที่สร้างจากโค้ด Delphi ก็ยังลงบนขอบเซลล์เหมือนเดิม offset เหล่านี้มีอยู่เพื่อให้ round trip ซื่อสัตย์ ไม่ใช่เพื่อเปิด sub-cell positioning เป็นฟีเจอร์ สังเกตว่าการแก้นี้แยกอิสระจาก fingerprint: anchor อยู่ใน drawing part ไม่ใช่ chart part ดังนั้นแผนภูมิที่ ChartML replay ได้สมบูรณ์แบบก็ยังกระโดดเข้ากริดอยู่ดีถ้าไม่มีการแก้นี้ ส่วนการแปลงหน่วยเบื้องหลังค่า EMU เหล่านั้นอธิบายไว้ในบันทึกเรื่องเรขาคณิตของรูปภาพใน HotXLS และการสเกลหน่วย EMU
จะพิสูจน์ได้อย่างไรว่าแผนภูมิ round-trip โดยไม่เปลี่ยน
ด้วยการเทียบ byte ไม่ใช่ด้วยการเปิดผลลัพธ์ใน Excel Excel ซ่อมและ normalize ตอนโหลดมากขนาดที่แผนภูมิที่เพี้ยนไปดูปกติดีจนกระทั่งนักวิเคราะห์สังเกตเห็นว่าสี marker เปลี่ยนไป เทสต์ใน corpus ที่จับ bug ทั้งสองตัวได้ทำสามอย่างหลังจาก open-and-save โดยไม่แก้ไขอะไรเลย: เดินผ่าน relationship ของเวิร์กชีต, drawing และแผนภูมิ แล้ว fail ถ้าเจอการอ้างอิงแผนภูมิที่ซ้ำ, กำพร้า หรือ dangling; เทียบ signature ของชนิดแผนภูมิ, สูตรของ series และเรขาคณิตของ anchor ระหว่างต้นฉบับกับ output; และสำหรับ two-charts.xlsx มันอ่าน xl/charts/chartN.xml แต่ละไฟล์จากทั้งสอง archive แล้วบังคับว่าต้อง byte ตรงกัน การตรวจแบบเดียวกันนี้เขียนใน Delphi ได้ง่าย ๆ ด้วย TZipFile จาก RTL
uses System.Zip, System.SysUtils;
function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
Src, Dst: TZipFile;
Name: string;
A, B: TBytes;
begin
Result := True;
Src := TZipFile.Create;
Dst := TZipFile.Create;
try
Src.Open(Original, zmRead);
Dst.Open(Resaved, zmRead);
for Name in Src.FileNames do
if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
begin
Src.Read(Name, A);
Dst.Read(Name, B); // จะ raise ถ้า part หายไป
if (Length(A) <> Length(B)) or
((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
begin
Writeln('changed: ', Name);
Result := False;
end;
end;
finally
Dst.Free;
Src.Free;
end;
end;
มีสามเงื่อนไขที่ทำให้การเทียบนี้มีความหมาย และแต่ละข้อก็พังเงียบ ๆ ถ้าลืม PreserveUnsupportedParts ต้องเป็น True ก่อน Open ไม่งั้นจะไม่มี raw byte ถูกเก็บเลยและทุกแผนภูมิจะถูกสร้างใหม่จาก model StrictOOXML ต้องเป็น False เพราะ strict mode บังคับให้สร้างใหม่โดยการออกแบบ และแอปพลิเคชันต้องไม่แตะแผนภูมิระหว่าง open กับ save — การอ่าน property ทำได้ แต่ setter ตัวไหนที่เปลี่ยน typed model จะพลิก fingerprint แล้วส่งแผนภูมิไปที่เส้นทาง merge ซึ่งเป็นพฤติกรรมที่ถูกต้องและไม่ใช่สิ่งที่เทสต์นี้ต้องการตรวจ chart part ยังถูก renumber จากตัวนับระดับเวิร์กบุ๊กตอน save ดังนั้นเวิร์กบุ๊กที่ลำดับชีตหรือลำดับแผนภูมิเปลี่ยนไปจะวาง byte ที่เหมือนกันไว้ใต้ชื่อ chartN.xml อีกชื่อ ตัวตรวจใน corpus จึงตาม relationship แทนที่จะตามชื่อ ด้วยเหตุผลข้อนี้
การแก้ทั้งสองอย่างออกใน HotXLS 2.382.0 และ 2.382.3 และผ่านการตรวจสอบบน Win32 กับ Win64 เทียบกับ corpus ท้องถิ่น โดยตัวอย่างแผนภูมิที่ save ใหม่ยังถูก render ผ่าน office suite อีกตัวเป็น PDF แล้วเทียบทีละหน้ากับต้นฉบับ HotXLS อ่าน แก้ไข และเขียนแผนภูมิ XLSX จากโค้ด Delphi และ C++Builder แบบเนทีฟโดยไม่ต้องติดตั้ง Excel ซึ่งนั่นคือสิ่งที่ทำให้ความเที่ยงตรงระดับนี้เป็นความรับผิดชอบของไลบรารี — หน้า HotXLS Delphi spreadsheet component มีรายการฟีเจอร์และตัวดาวน์โหลดทดลองใช้