LibreOffice와 여러분이 직접 만든 리더들이 아무 불평 없이 여는 XLSX에서 Excel이 "콘텐츠에 문제가 있습니다"를 띄우는 이유는, Excel이 그 리더들이 무시하는 두 가지를 강제하기 때문입니다. 스키마가 요구하는 속성과 Open Packaging Conventions의 유일성 규칙입니다. 생성한 파트와 보존한 파트를 섞어 쓰는 어떤 작성기든 세 규칙은 쉽게 깨뜨립니다. <phoneticPr>에는 fontId가 필요하고, 각 파트 이름은 [Content_Types].xml에서 정확히 하나의 Override를 가져야 하며, 두 루트 릴레이션십은 rId4 같은 식별자를 절대 공유하면 안 됩니다. 이 글은 셋을 하나씩 훑고, Delphi와 C++Builder용 네이티브 Excel 스프레드시트 컴포넌트인 HotXLS가 이를 만족시키도록 패키지를 쓰는 방법을 다룹니다
다른 모든 리더가 받아들이는 패키지를 Excel은 왜 거부할까
복구 프롬프트는 파서 실패가 아니라 스키마·패키지 검증기이기 때문입니다. 워크북은 여러분 라이브러리와 LibreOffice, XML 검증기를 거쳐 왕복하고, XLSX OPC 릴레이션십 해석 글에서 쓰는 OPC 의미로도 구조적으로 온전할 수 있습니다. 모든 파트에 도달할 수 있고 모든 대상이 해석됩니다. DisplayAlerts를 끈 격리된 COM 인스턴스에서 Workbooks.Open으로 열면 그래도 호출이 그대로 실패할 수 있습니다. 대화형으로는 같은 파일이 익숙한 복구 제안 대화상자를 띄우고, Excel이 로그를 쓰기는 할 때조차 파트 이름만 알려 줄 뿐 규칙은 알려 주지 않습니다. 독립적인 위반 여럿이 그 하나의 프롬프트 뒤에 숨을 수 있고, Excel은 하나씩 보고해 주지 않습니다. 워크북을 거부하고 찾아내고 분석하는 일을 남깁니다. 이어지는 내용은 규칙 하나하나, 그것을 어기기 쉬운 자연스러운 방법, 그리고 HotXLS가 대신 패키지를 쓰는 방식입니다. 셋 모두 Delphi XLSX 작성기라면 누구나 밟을 수 있는 규칙이기 때문입니다
규칙 1: phoneticPr의 fontId는 0일 때도 필수
<phoneticPr> 요소가 지니는 fontId 속성은 ECMA-376 Part 1 §18.4.3에서 use="required"로 선언되어 있고, 값 0은 없음이 아니라 합법적인 폰트 인덱스입니다. 0을 "설정되지 않음"으로 보고 Sheet.PhoneticFontId > 0일 때만 속성을 내보내는 작성기는 자연스러운 Delphi 반사 작용을 따른 것입니다. 정수 필드는 0으로 기본 설정되니까요. 하지만 그러면 음성 폰트가 하필 styles.xml의 첫 폰트인 워크북이 <phoneticPr type="noConversion"/>를 만들어 냅니다. 인덱스 0은 그냥 첫 폰트이므로 쉽게 걸립니다. Excel은 자기가 써 넣은 값을 되돌아오는 길에 거부합니다
// 자연스러운 반사 작용: 0을 미설정으로 보고 fontId를 생략
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
// lxHandleX.pas, 워크시트 작성기 — 속성은 필수이며 0도 포함
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';
HotXLS는 TXLSXWorksheet.PhoneticType이 비어 있지 않을 때만 이 요소를 내보내므로, 음성 설정을 담은 적 없는 워크북은 영향을 받지 않습니다. 새 시트에서 PhoneticFontId를 0으로 두고 저장하면 xl/worksheets/sheet1.xml에 <phoneticPr fontId="0"이 들어갑니다. 더 넓은 교훈은 "기본값이면 생략"이 스키마가 기본값을 선언했을 때만 안전하다는 것입니다. 그 요소에서 type과 alignment에는 기본값이 있지만 fontId에는 없습니다
규칙 2: [Content_Types].xml에서 파트 이름당 Override 하나
콘텐츠 타입 스트림은 각 파트 이름을 최대 한 번 선언할 수 있고, Excel은 같은 PartName에 대한 두 번째 Override를 두 항목의 ContentType이 같아도 손상으로 취급합니다. HotXLS에는 그 스트림에 값을 넣는 작성기가 둘 있습니다. BuildContentTypesXml은 객체 모델이 생성하는 모든 파트를 선언합니다. 워크북, 스타일, 공유 문자열, 테마, 워크시트, 그리고 TXLSXWorkbook.CustomProperties.Count > 0일 때 /docProps/custom.xml입니다. PreserveUnsupportedParts가 켜져 있으면 TXLSXOpaquePackage가 소스 패키지에서 그대로 캡처한 모든 파트에 Override를 덧붙여 그 바이트가 나가는 길에도 선언되게 합니다. 충돌은 양쪽에 다 있는 파트에서 생깁니다. 사용자 지정 문서 속성은 모델로 파싱되지만 소스 패키지의 docProps/custom.xml도 불투명하게 캡처되므로, 순진한 병합은 그것을 두 번 선언하고, 모델이 불투명 계층이 함께 보관한 파트를 다시 생성할 때는 차트와 피벗 캐시 파트도 같은 자리에 놓일 수 있습니다. 모델이 이미 쓴 내용을 볼 수 없는 불투명 작성기는 알 도리가 없습니다. 그래서 ContentTypeOverridesXml에 그 시야를 줍니다
<!-- 순진한 병합이 내놓는 것 -->
<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"/>
HotXLS는 생성된 XML을 ContentTypeOverridesXml에 넘겨 불투명 작성기가 무엇이든 내보내기 전에 그것을 파싱하게 합니다. 정확성은 두 디테일에 달려 있습니다. OpcLowerPartName은 비교 전에 소문자로 바꾸고 백슬래시를 슬래시로 뒤집으며 앞쪽 슬래시를 제거합니다. OPC 파트 이름은 대소문자를 구분하지 않고 비교되며, 모델은 앞에 슬래시를 붙여 쓰지만 불투명 계층은 ZIP 항목 이름을 슬래시 없이 저장하기 때문입니다. 그리고 BuildContentTypesXml의 호출자가 Result + '</Types>'를 넘겨 부분적으로 만들어진 문서를 닫아 주므로, TXMLReader는 잘린 스트림이 아니라 올바른 형식의 입력을 봅니다. 여기서 나오는 규칙은 모델을 앞세운 선점 우선입니다. 객체 모델이 선언한 것이 권위를 갖고, 불투명 재생은 빈자리만 채웁니다
// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
...
UsedNames.Sorted:= True;
UsedNames.Duplicates:= dupIgnore;
if ExistingXml<> '' then
// 모델이 생성한 스트림을 파싱해 선언된 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; // 이미 선언되었거나 rels 파트
UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
'" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
end;
end;
규칙 3: 릴레이션십 Id는 릴레이션십 파트 안에서 유일
.rels 파트의 모든 Relationship은 그 파트 안에서 유일한 Id를 가져야 하고, 둘이 하나를 공유하면 Excel은 패키지를 거부합니다. HotXLS는 패키지 수준 _rels/.rels를 고정 식별자로 씁니다. 통합 문서에 rId1, 핵심 및 확장 문서 속성에 rId2와 rId3, 모델에 사용자 지정 속성이 있을 때 rId4입니다. 그러면 불투명 패키지가 소스에서 보관한 루트 릴레이션십을 덧붙이면서 UsedIds 목록에 이미 있는 식별자의 번호를 다시 매깁니다. 그 목록이 rId1부터 rId3만 알고, 모델이 곧 자기 사용자 지정 속성 릴레이션십을 내보낼 것이라는 사실을 모른다면, 사용자 지정 속성 릴레이션십이 역시 rId4인 소스 패키지 — Excel이 기본으로 쓰는 값입니다 — 는 같은 대상을 가리키는 rId4 항목 두 개로 나옵니다. 그래서 RootRelationshipsXml은 모델이 사용자 지정 속성을 내보낼 때마다 rId4를 예약하고 소스 복사본을 건너뜁니다. 호출자 BuildRootRelsXml은 Workbook.FCustomProps.Count > 0을 두 번째 인수로 넘기므로, 예약과 건너뛰기는 모델이 rId4를 내보낼지 결정하는 조건과 같은 조건으로 구동됩니다. 패키지 루트에서는 번호 재지정이 안전합니다. 통합 문서 안의 어떤 것도 루트 릴레이션십 식별자를 이름으로 참조하지 않기 때문입니다. 같은 요령은 한 단계 아래에서 틀립니다. workbook.xml의 r:id 속성은 통합 문서 릴레이션십 파트의 식별자에 묶여 있으므로, MergeWorkbookRelationshipsXml이 별도의 식별자 맵을 유지합니다
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4'); // 모델 작성기가 예약한 값
if EmitDocProps then
begin
UsedIds.Add('rId2');
UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
// 사용자 지정 속성은 모델 소유이므로 소스 복사본을 재생하지 않는다
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;
세 규칙의 공통점은 무엇인가
셋 모두 소스가 둘인데 패키지 불변식의 주인이 하나도 없는 작성기를 방어합니다. 객체 모델은 자기가 이해하는 파트를 생성하고, 불투명 계층은 이해하지 못하는 파트를 재생합니다. 그래야 왕복에서 차트와 피벗 캐시, 사용자 지정 XML, 그 밖의 모든 것이 테마, extLst, calcChain의 무손실 왕복 노트에 설명된 대로 유지됩니다. 각자는 개별적으로 일관적일 수 있습니다. OPC가 패키지 전체에 부과하는 제약, 즉 유일한 Override 파트 이름과 파트별로 유일한 릴레이션십 식별자는 둘이 이어 붙는 이음매에만 존재하므로, 이음매의 주인이 필요합니다. fontId 규칙도 한 단계 아래의 같은 모양입니다. 작성기는 무엇을 생략할지 알지만 생략하면 안 된다고 말하는 스키마를 참조하지 않습니다. HotXLS는 병합 휴리스틱이 아니라 고정된 우선순위로 이것을 정리합니다. 모델이 먼저 쓰고, 불투명 계층은 쓰인 것을 보고 충돌이면 물러섭니다. 불변식은 외부에서 검사하기도 쉽습니다. 저장된 패키지의 [Content_Types].xml과 모든 .rels 항목을 읽어 중복된 PartName, Extension, Id에서 실패하면 됩니다. 이 검사에는 Excel이 필요 없고 유일성 규칙 둘을 커버하며, fontId 규칙은 여전히 스키마 검증기가 필요합니다
범위가 아니라 수식인 인쇄 영역
인쇄 영역은 Excel이 저장된 사본에서 비교하는 또 하나의 대상입니다. _xlnm.Print_Area가 $A$1:$J$29로 보고되던 템플릿은 왕복 뒤에도 똑같이 보고되어야 합니다. 그 이면에는 함정이 둘 있습니다. 임포트에서 첫 번째로 따옴표에 싸이지 않은 !까지를 자르는 접두사 제거는 OFFSET('Print Data'!$A$1,0,0,2,2) 같은 동적 인쇄 영역을 $A$1,0,0,2,2)로 만들고, 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 같은 정규화된 합집합은 첫 세그먼트에서만 접두사를 잃게 만듭니다. 내보내기에서는 저장된 PrintArea 전체에 시트 이름을 한 번만 앞에 붙이는 방식이 단순한 합집합 $A$1:$B$2,$D$1:$E$2에 첫 세그먼트만 정규화되고 두 번째는 맨몸인 상태를 남기는데, Excel은 이를 ECMA-376 Part 1 §18.2.5의 _xlnm.Print_Area 정의로 받아들이지 않습니다
// 임포트: 남는 것이 순수 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(...)는 건드리지 않고 반환
// 내보내기: 쉼표로 나뉜 모든 세그먼트를 정규화하거나 아무것도 하지 않음
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; // 수식이면 그대로 출력
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;
짝 규칙은 양쪽에서 같습니다. 인쇄 영역은 모든 세그먼트가 범위로 파싱될 때만 맨몸 범위이고, 그렇지 않으면 수식이므로 그대로 이동합니다. 이 규칙이 있으면 이름 기반, 시트 정규화 기반, 합집합이 모두 반복되는 저장과 재열기 주기를 살아남습니다. 인쇄 영역이 페이지 설정과 나머지 인쇄 모델과 어떻게 맞물리는지는 시트 보호, 페이지 설정, 인쇄 글에서 다룹니다
Excel이 어떤 규칙에 걸리는지 어떻게 찾을까
자기 검증기는 통과했으니 틀렸다고 가정하고 시작하십시오. Open XML SDK 검증기는 빠진 fontId 같은 스키마 위반을 파트와 XPath까지 알려 주고, 그 아래 패키징 계층은 콘텐츠 타입 항목이 중복된 패키지를 아예 열지 않으므로 무엇보다 먼저 돌려야 합니다. 검증기가 조용한데 Excel이 여전히 복구한다면 패키지를 이분하십시오. 압축을 풀고 파트 하나와 그 릴레이션십, Override를 지운 뒤 다시 압축해 열어 보는 식으로 프롬프트가 사라질 때까지 후보 집합을 절반씩 줄입니다. 위의 세 위반은 그 순서대로 나타나고, Excel이 저장하자고 제안하는 복구된 파일에서는 어느 것도 보이지 않습니다. 복구가 문제 항목을 조용히 버리거나 번호를 다시 매기기 때문입니다. HotXLS 작성기의 설계 선택 두 가지도 알아 둘 만합니다. 중복 제거는 모델을 앞세운 선점 우선이라, 모델도 생성하는 파트에 소스 패키지가 다른 콘텐츠 타입을 선언했다면 모델의 선언이 이기고 소스의 것은 버려집니다. HotXLS가 다시 생성하는 파트에는 올바르지만 일반적인 병합은 아닙니다. 그리고 생성된 콘텐츠 타입 스트림에 대한 추가 TXMLReader 패스는 PreserveUnsupportedParts가 켜진 모든 저장에서 실행되는데, 수 KB를 넘는 일이 드문 스트림에 대한 작은 비용입니다. 이 규칙들을 갖추면 Win32와 Win64 빌드가 저장한 4805개 수식의 대출 템플릿이 Excel에서 프롬프트 없이 열리고, 불일치 없이 재계산되며, 원본과 같은 인쇄 영역을 보고합니다
Delphi에서 직접 XLSX를 쓴다면 점검 목록은 짧습니다. 스키마가 필수로 표시한 속성은 값에 관계없이 내보내고, 각 파트 이름을 한 번만 선언하고, 그 파트를 건드리는 모든 작성기에 걸쳐 릴레이션십 파트별 사용 식별자 목록을 하나로 유지하십시오. 그 목록이 이미 존재하고 자기 리더가 아니라 Excel을 상대로 검증되기를 원한다면, 여기 설명한 패키지 작성기가 불투명 파트 왕복과 함께 HotXLS Delphi 스프레드시트 컴포넌트에 실려 있습니다. 그 이음매를 지킬 가치가 있게 만든 것이 바로 그 왕복입니다