기술 문서

Delphi에서 Excel이 유효한 XLSX를 복구하는 OPC 규칙

LibreOffice와 자체 제작 리더들이 아무 불평 없이 여는 XLSX에서 Excel이 "콘텐츠에 문제가 있습니다"를 띄우는 이유는, Excel이 그 리더들이 무시하는 두 가지를 강제하기 때문입니다. 스키마가 요구하는 속성과 Open Packaging Conventions의 유일성 규칙입니다. Delphi와 C++Builder용 네이티브 Excel 스프레드시트 컴포넌트인 HotXLS가 v2.382.5에서 출력을 실제 Excel COM 인스턴스에 처음 통과시켰을 때 정확히 그 문제를 만났고, 원인 세 가지는 fontId 없는 <phoneticPr>, [Content_Types].xml의 중복 Override, rId4를 공유한 루트 릴레이션십 두 개였습니다

다른 모든 리더가 받아들이는 패키지를 Excel은 왜 거부할까

복구 프롬프트는 파서 실패가 아니라 스키마 및 패키지 검증기의 결과이기 때문입니다. HotXLS 코퍼스는 4805개 수식 대출 서식을 몇 주 동안 라이브러리와 LibreOffice, 테스트 스위트의 XML 검증기를 거쳐 왕복시켜 왔습니다. 저장된 파일은 XLSX OPC 릴레이션십 해석 글에서 쓰는 OPC 의미로 구조적으로 온전했습니다. 모든 파트에 도달할 수 있고 모든 대상이 해석됩니다. 그런데 Excel 16.0 빌드 20326이 설치된 Windows 장비를 쓸 수 있게 되었고, 코퍼스 러너가 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은 없는 것이 아니라 합법적인 폰트 인덱스입니다. 예전 HotXLS 워크시트 작성기는 0을 "설정되지 않음"으로 보고 Sheet.PhoneticFontId > 0일 때만 속성을 내보냈습니다. 정수 필드가 0으로 기본 설정되는 Delphi의 자연스러운 습관이지만, 이 때문에 음성 폰트가 styles.xml의 첫 번째 폰트인 통합 문서가 <phoneticPr type="noConversion"/>를 만들어 냈고, HotXLS 코퍼스의 대출 서식이 정확히 그랬습니다. 그러면 Excel은 자기가 써 넣은 값을 되돌아오는 길에 거부합니다

Excel이 HotXLS 워크시트 파트에 복구를 요구한 이유: phoneticPr 요소는 ECMA-376 Part 1에서 fontId를 use required로 선언하고, 폰트 인덱스 0은 합법적인 값이며, PhoneticFontId가 0일 때 속성을 생략하던 예전 작성기는 phoneticPr type noConversion을 만들었고, 스키마는 type과 alignment에는 기본값을 주지만 fontId에는 주지 않습니다
속성이 기본값과 같을 때 생략하는 것은 스키마가 그 기본값을 선언했을 때만 안전하며, 대출 서식은 음성 폰트를 styles.xml의 맨 첫 항목으로 담고 있었습니다
// lxHandleX.pas, 워크시트 작성기 — v2.382.5 이전
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — 속성이 필수이며 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이 비어 있지 않을 때만 이 요소를 내보내므로, 음성 설정을 담은 적 없는 통합 문서는 영향을 받지 않습니다. 회귀 테스트 PhoneticSettings_DefaultFontIsExplicit는 새 시트에서 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도 불투명하게 캡처되어, 병합된 스트림이 그것을 두 번 선언했습니다. 차트와 피벗 캐시 파트도 모델이 불투명 계층이 함께 보관한 파트를 다시 생성할 때 같은 자리에 놓일 수 있습니다. v2.382.5 이전의 ContentTypeOverridesXml은 모델이 이미 쓴 내용을 볼 방법이 없었으므로 알 수 없었습니다

[Content_Types].xml에서 HotXLS 작성기 두 개가 충돌한 방식: BuildContentTypesXml이 객체 모델에서 docProps/custom.xml을 선언하는 동안 TXLSXOpaquePackage가 그대로 캡처한 같은 파트에 Override를 덧붙였고, v2.382.5부터는 불투명 계층이 생성된 스트림을 먼저 파싱하고 OpcLowerPartName으로 이름을 정규화해 모든 충돌에서 모델이 이깁니다
각 작성기는 개별적으로는 일관되었고, 각 파트 이름이 한 번만 나와야 한다는 제약은 두 출력이 이어 붙는 이음매에만 존재합니다. 그래서 수정이 모델 스트림을 넘겨주는 것입니다
<!-- v2.382.5 이전에 Excel이 본 내용 -->
<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에 넘겨 불투명 작성기가 무엇이든 내보내기 전에 그것을 파싱하게 합니다. 정확성은 두 가지 세부에 달려 있습니다. 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는 몰랐고, 모델이 곧 자기 사용자 지정 속성 릴레이션십을 내보낼 것이라는 사실도 몰랐습니다. 그래서 사용자 지정 속성 릴레이션십이 역시 rId4인 소스 패키지 — Excel이 기본으로 쓰는 값입니다 — 는 같은 대상을 가리키는 rId4 항목 두 개로 나왔습니다. 이제 호출자 BuildRootRelsXml이 Workbook.FCustomProps.Count > 0을 두 번째 인수로 넘기므로, 예약과 건너뛰기가 모델이 rId4를 내보낼지 결정하는 것과 같은 조건으로 구동됩니다. 패키지 루트에서는 번호 재지정이 안전합니다. 통합 문서 안의 어떤 것도 루트 릴레이션십 식별자를 이름으로 참조하지 않기 때문입니다. 같은 요령을 한 단계 아래에서 쓰면 틀립니다. workbook.xml의 r:id 속성이 통합 문서 릴레이션십 파트의 식별자에 묶여 있고, 그래서 MergeWorkbookRelationshipsXml이 별도의 식별자 맵을 유지합니다

HotXLS 패키지 루트에서의 릴레이션십 식별자 충돌: 모델은 rId4를 사용자 지정 속성용으로 예약해 두고 rId1부터 rId4까지 쓰며, 불투명 계층은 UsedIds가 rId1부터 rId3까지만 알고 있었기 때문에 역시 rId4로 도착한 소스 릴레이션십을 재생했고, 수정은 EmitCustomProps가 참일 때 rId4를 미리 예약하고 나머지 번호를 다시 매깁니다
통합 문서 안의 어떤 것도 루트 식별자를 이름으로 참조하지 않으므로 패키지 루트에서 번호 재지정은 안전하며, 한 단계 아래에서 같은 요령을 쓰면 workbook.xml의 모든 r:id 바인딩이 깨집니다
// 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 파트 이름과 파트별로 유일한 릴레이션십 식별자 — 은 둘이 이어 붙는 그 이음매에만 존재하는데, v2.382.5까지 아무도 이음매를 검사하지 않았습니다. fontId 버그도 한 단계 아래의 같은 모양입니다. 작성기는 무엇을 생략하고 싶은지는 알았지만 생략하면 안 된다고 말하는 스키마를 참조하지 않았습니다. HotXLS가 정착한 해법은 병합 휴리스틱이 아니라 고정된 우선순위입니다. 모델이 먼저 쓰고, 불투명 계층은 쓰인 것을 보고 충돌이면 물러서며, 이제 코퍼스 러너가 verify_opc_uniqueness로 불변식을 외부에서 강제합니다. 이 검사는 저장된 패키지의 [Content_Types].xml과 모든 .rels 항목을 읽어 중복된 PartName, Extension, Id가 있으면 케이스를 실패시킵니다. 비용이 싸고 Excel이 필요 없으며, 첫 코퍼스 실행에서 세 결함 중 둘을 잡았을 것입니다

같은 배치: 범위가 아니라 수식인 인쇄 영역

Excel 통과 과정에서 대출 서식의 _xlnm.Print_Area도 문제로 잡혔습니다. Excel이 원본에서 $A$1:$J$29로 보고했고 저장된 사본에서도 똑같이 보고해야 했습니다. 그 하나의 단언 뒤에 별개의 버그 두 개가 있었습니다. 임포트할 때 XlsxStripSheetPrefix가 첫 번째로 따옴표에 싸이지 않은 !까지를 잘라 내서, 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;

양쪽의 짝 규칙은 같습니다. 인쇄 영역은 모든 세그먼트가 범위로 파싱될 때만 맨몸 범위이고, 그렇지 않으면 수식이므로 그대로 이동합니다. PrintArea_FormulaDefinitionSurvivesRoundTrip은 이름 기반, 시트 정규화 기반, 합집합을 두 번의 저장과 재열기 주기에 걸쳐 검사합니다. 인쇄 영역이 페이지 설정과 나머지 인쇄 모델과 어떻게 맞물리는지는 시트 보호, 페이지 설정, 인쇄 글에서 다룹니다

Excel이 어떤 규칙에 걸리는지 어떻게 찾을까

먼저 자기 검증기가 틀렸다고 가정하고 시작하십시오. 검증기는 통과했기 때문입니다. Open XML SDK 검증기는 빠진 fontId 같은 스키마 위반을 파트와 XPath까지 알려 주고, 그 아래 패키징 계층은 콘텐츠 타입 항목이 중복된 패키지를 아예 열지 않으므로 무엇보다 먼저 돌려야 합니다. 검증기가 조용한데 Excel이 여전히 복구한다면 패키지를 이분 탐색하십시오. 압축을 풀고 파트 하나와 그 릴레이션십, Override를 지운 뒤 다시 압축해 열어 보는 식으로 프롬프트가 사라질 때까지 후보 집합을 절반씩 줄입니다. 여기 나온 세 결함은 그 순서대로 나왔고, Excel이 저장하자고 제안하는 복구된 파일에서는 어느 것도 눈에 띄지 않았을 것입니다. 복구가 문제 항목을 조용히 버리거나 번호를 다시 매기기 때문입니다. v2.382.5 수정의 경계도 똑같이 분명히 밝힐 가치가 있습니다. 중복 제거는 모델을 앞세운 선점 우선이므로, 모델도 생성하는 파트에 소스 패키지가 다른 콘텐츠 타입을 선언했다면 모델의 선언이 이기고 소스의 것은 버려집니다. HotXLS가 다시 생성하는 파트에는 올바르지만 일반적인 병합은 아닙니다. verify_opc_uniqueness는 유일성만 검사하고 스키마는 검증하지 않으므로, 나중에 필수 속성이 생기면 여전히 Excel이나 스키마 검증기가 드러내야 합니다. 그리고 생성된 콘텐츠 타입 스트림에 대한 추가 TXMLReader 패스는 PreserveUnsupportedParts가 켜진 모든 저장에서 실행되는데, 수 KB를 넘는 일이 드문 스트림에 대한 작은 비용입니다. 이 조치를 갖추고 나서 대출 서식의 Win32와 Win64 빌드 모두 Excel에서 프롬프트 없이 열리고, 검증된 4805개 수식을 불일치 없이 재계산하며, 원본과 같은 인쇄 영역을 보고합니다

Delphi에서 직접 XLSX를 쓴다면 점검 목록은 짧습니다. 스키마가 필수로 표시한 속성은 값에 관계없이 내보내고, 각 파트 이름을 한 번만 선언하고, 그 파트를 건드리는 모든 작성기에 걸쳐 릴레이션십 파트별 사용 식별자 목록을 하나로 유지하십시오. 그 목록이 이미 존재하고 자기 리더가 아니라 Excel을 상대로 검증되기를 원한다면, 여기 설명한 패키지 작성기가 불투명 파트 왕복과 함께 HotXLS Delphi 스프레드시트 컴포넌트에 실려 있습니다. 그 이음매를 지킬 가치가 있게 만든 것이 바로 그 왕복입니다