HotXLS ซึ่งเป็น component สเปรดชีต Excel แบบ native สำหรับ Delphi และ C++Builder ship fix ที่เกี่ยวกับ AGGREGATE สองรายการในเดือนกันยายน 2026 เวอร์ชัน 2.382.0 แก้ argument options ให้รหัส 1/3/5/7 ไม่สนใจแถวที่ซ่อน, 2/3/6/7 ไม่สนใจค่า error และ 0 ถึง 3 ไม่นับเซลล์ SUBTOTAL กับ AGGREGATE ที่ซ้อนอยู่ ตรงตามที่ Microsoft ระบุไว้ เวอร์ชัน 2.382.3 จึงหยุดไม่ให้ flag การเลือกเหล่านั้นรั่วเข้าไปในการประเมินเซลล์ที่ฟังก์ชันอ้างถึงนั่นเอง ข้อบกพร่องตัวแรกน่าอายแบบที่ bug จากการคัดตารางมักเป็น: ตำแหน่งบิตถูกสลับกัน ทุกสูตรที่ใช้รหัส options ที่ไม่ใช่ศูนย์จึงได้นโยบายที่ผู้เขียนไม่ได้ขอ ตัวที่สองน่าสนใจกว่าเพราะเป็นรูปร่างที่คุณจะเจอใน evaluator ใดก็ตามที่ใช้ฟิลด์ชั่วคราวส่ง context เข้าไปในการเดินแบบ recursive การรวมค่าชั้นนอกตั้ง flag ตัวหนึ่ง เดินไปตามช่วง และดึงเซลล์ที่สูตรยังไม่ถูกคำนวณ สูตรนั้นรันบน calculator ตัวเดียวกัน เห็น flag ที่ตั้งอยู่ตัวเดียวกัน และรวมค่าผิดแถวเงียบ ๆ ให้ตัวเลขที่คลาดไปเท่าไรก็ไม่มีใครอธิบายได้จากข้อความสูตรเพียงอย่างเดียว
options 0 ถึง 7 ของ AGGREGATE เลือกอะไรจริง ๆ
argument options ของ AGGREGATE เป็นเมทริกซ์สามบิต และบิตทั้งสามเป็นอิสระต่อกัน บิต 0 (ค่า 1) หมายถึงไม่สนใจแถวที่ซ่อน บิต 1 (ค่า 2) หมายถึงไม่สนใจค่า error และบิต 2 (ค่า 4) หมายถึง หยุดไม่สนใจเซลล์ SUBTOTAL กับ AGGREGATE ที่ซ้อนอยู่ เพราะการข้ามมันเป็นค่าดีฟอลต์ของรหัสต่ำ สองอย่างในนี้ที่กลับกันได้ง่าย บิตของแถวที่ซ่อนคือบิตต่ำ ไม่ใช่บิตกลาง ดังนั้น AGGREGATE(9,1,...) คือรูปแบบรวมค่าแบบกรองแล้ว และ AGGREGATE(9,2,...) คือแบบที่ทน error และนโยบายการซ้อนกลับกันกับอีกสองข้อ: มีแค่รหัส 4 ถึง 7 ที่ถือว่าเซลล์ที่สูตรของตัวเองเป็น SUBTOTAL หรือ AGGREGATE เป็นค่าปกติ ECMA-376 Part 1 §18.17.7 นิยาม SUBTOTAL ด้วยการแบ่งรวมหรือไม่รวมแถวที่ซ่อนแบบเดียวกันในรหัส 1-11 กับ 101-111 และ AGGREGATE ซึ่งเก็บในไฟล์ OOXML ใต้ prefix _xlfn. ทั่วไปการแบ่งนั้นให้อยู่ใน argument options ตารางที่ Microsoft เผยแพร่สำหรับฟังก์ชัน AGGREGATE จึงเป็น contract ที่ engine ต้องทำได้ ไม่ใช่ความสะดวก
| Option | แถวที่ซ่อน | ค่า error | SUBTOTAL / AGGREGATE ที่ซ้อน |
|---|---|---|---|
| 0 | นับรวม | ส่งต่อ | ไม่สนใจ |
| 1 | ไม่สนใจ | ส่งต่อ | ไม่สนใจ |
| 2 | นับรวม | ไม่สนใจ | ไม่สนใจ |
| 3 | ไม่สนใจ | ไม่สนใจ | ไม่สนใจ |
| 4 | นับรวม | ส่งต่อ | นับรวม |
| 5 | ไม่สนใจ | ส่งต่อ | นับรวม |
| 6 | นับรวม | ไม่สนใจ | นับรวม |
| 7 | ไม่สนใจ | ไม่สนใจ | นับรวม |
ทำไม HotXLS ถึงมี options ของ AGGREGATE กลับด้าน
เพราะ TXLSCalculator.CalcAggregateFunc ตัวเดิมเขียนจากคำถอดความของตาราง ไม่ใช่จากตัวตาราง มันคำนวณ ignoreErrors := (optCode >= 4) and (optCode <= 7) และตั้ง gate ของแถวที่ซ่อนสำหรับรหัส 2, 3, 6 และ 7 ขณะที่ไม่ได้ implement นโยบายการซ้อนเลย บทความก่อนหน้าเรื่อง SUBTOTAL กับ AGGREGATE และแถวที่ซ่อน ลิสต์ช่องว่างนั้นเป็นข้อจำกัดที่เปิดอยู่ และอธิบายการ map แบบเก่าตามที่มัน ship ตอนนั้น คำอธิบายนั้นถูกต้องเกี่ยวกับโค้ดและผิดเกี่ยวกับ Excel และไม่มีใครสังเกตมานานเพราะสองนโยบายที่คนส่วนใหญ่ใช้ร่วมกัน คือซ่อนกับ error ตกลงที่รหัส 3 กับ 7 ใต้ตารางทั้งสองแบบ มีแค่รหัสบิตเดียวที่เปิดโปงการสลับ: AGGREGATE(9,1,A1:A4) คืนผลรวมที่ไม่ได้กรอง และ AGGREGATE(9,2,...) ข้ามแถวที่ซ่อนขณะที่ยังส่งต่อ #DIV/0! ข้อบกพร่องโผล่จากการตรวจ lxCalc.pas แบบ static บันทึกเป็น HXLS-008 ในทะเบียน known-issues ของโปรเจกต์ ไม่ได้มาจากไฟล์ลูกค้า ซึ่งบอกอะไรได้บ้างว่ามีโอกาสแค่ไหนที่รหัสบิตเดียวจะโผล่ในสมุดงานจริง เวอร์ชัน 2.382.0 เขียนการ decode ใหม่เป็นการทดสอบการเป็นสมาชิกของเซตสามชุด และเพิ่ม gate ตัวที่สองสำหรับนโยบายการซ้อน เชื่อมผ่าน callback ใหม่ TXLSIsSubtotalCell ที่สมุดงานจัดมาให้ข้าง ๆ TXLSIsRowHidden
// TXLSCalculator.CalcAggregateFunc, รูปแบบ v2.382.3
if (optCode < 0) or (optCode > 7) then
begin
Result := lxErrorValue; // Excel ปฏิเสธรหัสนอกช่วง 0..7
Exit;
end;
ignoreErrors := optCode in [2, 3, 6, 7];
prevIgnoreHidden := FIgnoreHiddenRows;
prevIgnoreSubtotal := FIgnoreSubtotalCells;
FIgnoreHiddenRows := (optCode in [1, 3, 5, 7]) and Assigned(FIsRowHidden);
FIgnoreSubtotalCells := (optCode in [0, 1, 2, 3]) and Assigned(FIsSubtotalCell);
try
// ... map function_num ไปที่ iftab ภายใน แล้วเดิน ref1..refN ...
finally
FIgnoreHiddenRows := prevIgnoreHidden;
FIgnoreSubtotalCells := prevIgnoreSubtotal;
end;
สังเกตว่า flag ทั้งสองถูก assign แบบไม่มีเงื่อนไข ไม่ได้ตั้งเฉพาะเมื่อ option ขอ เวอร์ชัน 2.382.0 ยังใช้ if ... then FIgnoreHiddenRows := True ซึ่งหมายความว่า AGGREGATE รหัส 4 ที่ซ้อนอยู่ใน SUBTOTAL(109, ...) สืบทอด gate แถวที่ซ่อนของชั้นนอกมาแทนที่จะล้างมัน การ assign ค่าที่ decode ได้ตอนเข้าและคืนค่าเดิมในบล็อก finally ทำให้ทุกการเรียก AGGREGATE เป็นเจ้าของนโยบายของตัวเองตลอดการเดินของมันและไม่มากกว่านั้น เวอร์ชัน 2.382.0 ยังทำให้รูป array ซื่อสัตย์ด้วย: เมื่อ argument ประเมินออกมาเป็น Variant array หนึ่งหรือสองมิติ CalcAggregateFunc ตอนนี้เดินทุกสมาชิกและใช้นโยบาย error ต่อสมาชิก ขณะที่โค้ดเดิมทดสอบแค่ว่า double เป็น NaN หรือไม่ และไม่งั้นก็ส่ง array ทั้งก้อนให้ ExcelSum
ทำไม AGGREGATE ชั้นนอกรั่วเข้าไปในสูตรที่มันอ้างถึง
เพราะ FIgnoreHiddenRows กับ FIgnoreSubtotalCells เป็นฟิลด์บน calculator และ calculator ถูกใช้ร่วมกันโดยทุกสูตรที่ถูกประเมินระหว่างการคำนวณใหม่หนึ่งรอบ gate เหล่านี้ถูกออกแบบเป็นฟิลด์ชั่วคราวก็เพื่อให้ loop เดินเซลล์หกตัวปรึกษามันได้โดยไม่ต้องส่ง parameter ผ่านทุก signature และการออกแบบนั้นก็ใช้ได้ตราบใดที่ทุกอย่างที่รันระหว่าง gate ถูกตั้งเป็นของ aggregation ที่ตั้งมัน สมมติฐานนั้นพังที่จุดหนึ่งโดยเฉพาะ: FGetValue เมื่อ walker ขอค่าเซลล์จากสมุดงานและเซลล์นั้นมีสูตรที่ไม่มีผลลัพธ์ที่ cache ไว้ สมุดงานจะคอมไพล์สูตรและประเมินมันทันที บน TXLSCalculator ตัวเดียวกัน โดย gate ชั้นนอกยังตั้งอยู่ fixture regression ใน HotXLS.WorkbookApiTests.pas แสดงความล้มเหลวด้วยสี่เซลล์ A1 มี 10, A2 มี 20 บนแถวที่ซ่อน, A3 มี =1/0 และ A4 มี =SUBTOTAL(9,A1:A2) ซึ่งค่าที่ถูกต้องคือ 30 ทีนี้ลองประเมิน =AGGREGATE(9,7,A1:A4): ไม่สนใจแถวที่ซ่อน ไม่สนใจ error และนับ subtotal ที่ซ้อนเป็นค่า Excel คืน 10 + 30 = 40 เมื่อ A4 ไม่ถูก cache engine ก่อน 2.382.3 ตั้ง gate แถวที่ซ่อน เดินไปถึง A4 ทำให้มันถูกประเมิน และ CalcSubtotalFunc สำหรับรหัส 9 สืบทอด gate ที่ตั้งอยู่ เพราะมันตั้ง flag เฉพาะรหัส 101 ถึง 111 และไม่เคยล้างมัน A4 ประเมินออกมาเป็น 10 แทน 30 และผลรวมชั้นนอกจากกลับมาเป็น 20 ไม่มีอะไรในสูตรทั้งสองที่พูดถึงแถวที่ซ่อนบนเส้นทางที่ให้ตัวเลขผิดนั้น
gate ของการซ้อนรั่วแบบเดียวกันในทิศกลับกัน ด้วยรหัส 0 ถึง 3 FIgnoreSubtotalCells ถูกตั้ง และ walker ช่วงแบบทั่วไปใน GetValueItemRange ทำตามมัน สูตรที่ถูกอ้างซึ่งเป็น =SUM(B1:B3) จึงทิ้ง B2 เงียบ ๆ ถ้า B2 บังเอิญมี SUBTOTAL ที่แย่กว่านั้น CalcSubtotalFunc รีเซ็ต FIgnoreSubtotalCells เป็น False ตอนออกแทนที่จะคืนค่าเดิม SUBTOTAL ที่ไม่ถูก cache ซึ่งถูกไปถึงกลางการเดินจึงปลด gate ชั้นนอกให้ทุกเซลล์หลังจากนั้น ทะเบียน known-issues ของโปรเจกต์บันทึกเรื่องนี้ใต้ HXLS-008 ว่า nested selection state leakage และนั่นคือชื่อที่ถูกสำหรับ bug ประเภทนี้: flag ชั่วคราวระดับ global ที่ถูกสำหรับ frame ที่ตั้งมันและผิดสำหรับทุก frame ที่สืบทอดมัน
AggregateGetCellValue กับ AggregateGetItemValue กันการเดินอย่างไร
fix ใน v2.382.3 วางขอบเขตไว้รอบทุกจุดที่ AGGREGATE อ่านค่าที่มันไม่ได้คำนวณเอง TXLSCalculator.AggregateGetCellValue ห่อการเรียก FGetValue ดิบ: มันเซฟ flag ทั้งสอง ล้างมัน ดึงค่า และคืนมันในบล็อก finally aggregation ชั้นนอกยังใช้นโยบายของตัวเองกับเซลล์ที่เพิ่งดึงมา เพราะการทดสอบแถวที่ซ่อนและเซลล์ที่ซ้อนเกิดใน walker รอบ ๆ การดึง แต่สูตรที่ถูกอ้างรันโดยไม่มีนโยบายใดเลย ซึ่งก็คือสิ่งที่ Excel ทำ
function TXLSCalculator.AggregateGetCellValue(SheetIndex, Row, Col: Integer;
var Value: Variant; var OutOfRange: Boolean): Integer;
var
Hidden, Nested: Boolean;
begin
Hidden := FIgnoreHiddenRows;
Nested := FIgnoreSubtotalCells;
FIgnoreHiddenRows := False; // สูตรที่ถูกอ้างเป็นเจ้าของนโยบายของตัวเอง
FIgnoreSubtotalCells := False;
try
Result := FGetValue(SheetIndex, Row, Col, Value, OutOfRange);
finally
FIgnoreHiddenRows := Hidden;
FIgnoreSubtotalCells := Nested;
end;
end;
AggregateGetItemValue ทำแบบเดียวกันกับ argument ที่ไม่ใช่ช่วง และมันต้องทำมากกว่าการล้าง flag เพราะ argument อย่าง A1:A4/(B1:B4-20) เป็น array ที่คำนวณได้และรูปร่างของสมาชิกต้องรอดไปให้ได้ wrapper นี้ materialize ช่วงธรรมดาเป็น Variant array สองมิติผ่าน AggregateGetCellValue โดย map เซลล์ที่คืนรหัส error ไปเป็น VarAsError เพื่อให้นโยบาย error ยังใช้ต่อสมาชิกได้ และมันไล่ต่อผ่าน node ของ operator ไบนารีและยูนารี (SA_ADD, SA_DIV, SA_UNARMINUS และอื่น ๆ) ด้วย ApplyArrayBinaryOp กับ ApplyArrayUnaryOp นอกนั้นตกไปที่ GetValueItem ปกติ มี guard สองข้ออยู่หน้าการ materialize: ช่วงที่ใหญ่กว่า EffectiveFormulaArrayMemoryLimit คืน lxErrorResourceLimit และช่วงที่คร่อมหลายชีตหรือกลับหัวคืน #VALUE! รหัส resource limit จงใจไม่ถูกถือเป็น error ของเซลล์ที่ข้ามได้แม้ภายใต้ option 2/3/6/7 เพราะ engine ที่กลืนสัญญาณ out-of-memory ของตัวเองเพราะผู้ใช้ขอให้ข้าม #N/A ก็เท่ากับโกหก walker ทั้งสามตัวของ AGGREGATE คือ AggregateCollectRange สำหรับตระกูล SUM, AggregateReduceVariance สำหรับ STDEV, VAR และ PRODUCT และ AggregateReduceWithK สำหรับ MEDIAN และรูปแบบ quantile ถูกเปลี่ยนจาก FGetValue กับ GetValueItem มาใช้ wrapper สองตัว และแต่ละตัวได้การทดสอบเซลล์ที่ซ้อนผ่าน FIsSubtotalCell เพิ่ม
AGGREGATE คืน error ตัวไหนเมื่อมันไม่ข้าม error
ตัวเดิม ตั้งแต่ v2.382.3 เวอร์ชัน 2.382.0 ตรวจเซลล์ error ได้ถูกต้องแต่ยุบทุกตัวให้เป็น lxErrorValue AGGREGATE(9,4,A1:A3) บนเซลล์ #DIV/0! จึงคืน #VALUE! ขณะที่ Excel ส่งต่อ error ตัวแรกที่เจอโดยไม่เปลี่ยน helper ที่มาแทนอย่าง AggregateErrorCode map Variant ไปเป็นรหัส lxError* ที่ตรงกัน ไม่ว่า Variant นั้นจะเป็น varError จริงหรือหนึ่งในสตริง error เจ็ดตัว และ AggregateValueIsError ตอนนี้ก็แค่ทดสอบว่าผลลัพธ์ไม่ใช่ศูนย์ walker แต่ละตัวบันทึกรหัส error ตัวแรกที่เจอและคืนรหัสนั้น ซึ่งหมายความว่าเซลล์ที่สูตรไม่เคยถูกคำนวณและ error ของมันจึงมาในรูป return code จาก FGetValue แทนที่จะเป็น Variant ที่ cache ไว้ ก็ส่งต่อแบบเดียวกับตัวที่ cache ไว้ ฟังก์ชันนับสองตัวได้รับการจัดการพิเศษใน AggregateCollectRange และการจัดการนั้นตรงกับ SUBTOTAL ไม่ใช่ SUM สำหรับฟังก์ชันภายใน 0 คือ COUNT เซลล์ error ไม่ถูกนับและไม่ถูกส่งต่อไม่ว่ารหัส options จะเป็นอะไร เพราะ COUNT นับเฉพาะตัวเลข สำหรับฟังก์ชันภายใน 169 คือ COUNTA เซลล์ error เป็นค่าที่ไม่ว่างและนับเป็น 1 เว้นแต่รหัส options ไม่สนใจ error ซึ่งในกรณีนั้นมันถูกข้าม ความไม่สมมาตรนี้คือวิธีที่ Excel ปฏิบัติกับ COUNT และ COUNTA นอก AGGREGATE ด้วย และเป็นรายละเอียดแบบที่กฎ ถ้าเป็น error ก็ส่งต่อ แบบทั่วไปทำพลาดเงียบ ๆ
เมทริกซ์ regression แปด option ตรวจอะไร
fixture ที่อธิบายข้างบนถูกรันเป็นเมทริกซ์เต็มใน AggregateFunc_OptionMatrixCoversHiddenErrorsAndNestedAggregates: สำหรับรหัส options แต่ละตัวตั้งแต่ 0 ถึง 7 มันประเมินทั้งรูป SUM และรูป MEDIAN บน A1:A4 และตรวจผลกับค่าที่คำนวณด้วยมือ รหัส 0, 1, 4 และ 5 ต้องส่งต่อ #DIV/0! จาก A3 เพราะไม่มีตัวไหนข้าม error รหัส 2 ให้ SUM 30 และ MEDIAN 15 จาก 10 กับ 20 โดยข้าม A4 ที่ซ้อนอยู่ รหัส 3 ให้ 10 กับ 10 รหัส 6 ให้ 60 กับ 20 เพราะ 30 ใน A4 ถูกนับตอนนี้ รหัส 7 ให้ 40 กับ 20 ซึ่งเป็นเคสที่คืน 20 ก่อน fix เรื่องการรั่ว การรัน acceptance ที่กว้างกว่าซึ่งบันทึกในทะเบียน known-issues ครอบคลุมหมายเลขฟังก์ชันทั้งสิบเก้าตัวกับรหัสทั้งแปด โดยมีสูตรที่ถูกอ้างทั้งแบบ cache และไม่ cache รวม 304 สถานการณ์บน Win32 และ Win64
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Data');
Sheet.Cells[1, 1].Value := 10;
Sheet.Cells[2, 1].Value := 20;
Sheet.Cells[3, 1].Formula := '=1/0';
Sheet.Cells[4, 1].Formula := '=SUBTOTAL(9,A1:A2)'; // subtotal ของกลุ่ม = 30
Sheet.RowHidden[2] := True;
Sheet.Cells[6, 1].Formula := '=AGGREGATE(9,1,A1:A4)'; // #DIV/0! ข้ามแถวที่ซ่อน error ถูกส่งต่อ
Sheet.Cells[7, 1].Formula := '=AGGREGATE(9,3,A1:A4)'; // 10 ข้ามทั้งแถวที่ซ่อน error และการซ้อน
Sheet.Cells[8, 1].Formula := '=AGGREGATE(9,6,A1:A4)'; // 60 ข้ามเฉพาะ error
Sheet.Cells[9, 1].Formula := '=AGGREGATE(9,7,A1:A4)'; // 40 เคยเป็น 20 ก่อน v2.382.3
Book.Recalculate;
Book.SaveAs('aggregate-options.xlsx');
finally
Book.Free;
end;
end;
ขอบเขตยังอยู่ตรงไหน
มีข้อจำกัดสามข้อที่ควรรู้ก่อนสร้างทับสิ่งนี้ ข้อแรก ตัวตัดสินการซ้อนเป็นเชิงข้อความ TXLSXWorkbook.GetCalcIsSubtotalCell กับฝาแฝดใน engine แบบ classic ตอบ True เมื่อสูตรของเซลล์เริ่มด้วย SUBTOTAL(, AGGREGATE( หรือ _xlfn.AGGREGATE( มีหรือไม่มีเครื่องหมายเท่ากับนำหน้าก็ได้ สูตรอย่าง =IF(C1,SUBTOTAL(9,B1:B9),0) หรือ =SUBTOTAL(9,B1:B9)*2 จึงไม่ถูกมองว่าเป็นการซ้อนและจะถูกนับซ้ำโดยรหัส 0 ถึง 3 ในจุดที่ Excel จะข้ามมัน generator ที่ปล่อย subtotal ที่คำนวณได้ควรเก็บการเรียก aggregation ไว้ที่หัวสูตร ข้อที่สอง การกันไว้อยู่ใน walker ทั้งสามตัวของ AGGREGATE CalcSubtotalFunc ยังเดินผ่าน GetValueItemRange, CollectRangeValues และ SubtotalReduceVariance ซึ่งเรียก FGetValue ตรง ๆ SUBTOTAL(109, ...) ที่ช่วงมีสูตรซึ่งถูกอ้างและไม่ถูก cache จึงยังส่ง gate แถวที่ซ่อนของมันเข้าไปในสูตรนั้นได้ การ Recalculate เต็มรูปแบบประเมินสูตรที่ถูกอ้างก่อนสูตรที่พึ่งมัน เส้นทางที่ cache จึงถูกใช้และ gate ไม่เคยถูกสืบทอด ความเสี่ยงจำกัดอยู่ที่การประเมินแบบเฉพาะกิจผ่าน Calculate และสมุดงานที่โหลดมาโดยไม่มีค่าที่ cache และถ้าคุณพึ่งการคำนวณใหม่แบบ incremental บน dependency graphเพื่อให้โมเดลใหญ่ตอบสนองไว หลักประกันเรื่องลำดับเดียวกันนั่นเองที่ทำให้การรั่วนี้หลับอยู่ ข้อที่สาม gate ทั้งสองมีเงื่อนไข Assigned(FIsRowHidden) และ Assigned(FIsSubtotalCell) facade ของสมุดงานทั้งสองแบบต่อ callback ใน constructor แต่โค้ดที่สร้าง TXLSCalculator เองด้วย argument สองตัวเดิมจะได้พฤติกรรมแบบรวมทุกอย่างแบบเก่าสำหรับทุกรหัส options เงียบ ๆ เมื่อผลรวมดูผิดและข้อความสูตรดูถูก การ trace การประเมินทีละขั้นเป็นวิธีเร็วที่สุดที่จะเห็นว่าสูตรที่ถูกอ้างถูกประเมินภายใต้ gate ที่สืบทอดมา หรือ callback แค่ไม่เคยถูกต่อเลย
calculation engine ที่อธิบายตรงนี้ ตัว decode option, wrapper การดึงค่าที่ถูกกันไว้ และเมทริกซ์ regression ที่ล็อกมันไว้ ทั้งหมด ship เป็น source พร้อม HotXLS Delphi spreadsheet component ซึ่งอ่าน เขียน และคำนวณใหม่ไฟล์ XLS, XLSX และ ODS ใน Delphi และ C++Builder โดยไม่ต้องติดตั้ง Excel