기술 문서

BIFF SupBook과 XTI 외부 링크 분류 델파이

오래된 xls를 열고 다시 저장하면, 등록된 분석 라이브러리를 호출하던 애드인 수식이 이제 통합 문서 안의 빈 참조를 가리킵니다. HotXLS는 그 조용한 손상을 하나의 나쁜 가정으로 추적합니다. BIFF SupBook 레코드가 self이거나 외부 파일이라는 가정입니다. [MS-XLS]는 두 종이 아니라 일곱 종을 정의합니다

저장된 통합 문서는 어째서 애드인 링크를 잃는가

분류 검사가 타입이 아니라 구조였기 때문입니다. 전통적인 지름길은 SupBook 레코드($01AE)를 읽고 self 마커를 실었는지 검사한 다음, 아니라면 뒤따르는 문자열을 무조건 문서 URL로 다룹니다. 그 둘 어디에도 해당하지 않는 모든 레코드는 기본 분기로 떨어지고, 기본 분기는 거의 언제나 “이것은 통합 문서 자신이다”입니다. 애드인 서포팅 링크, same-sheet 링크, 사용되지 않는 슬롯, 잘린 레코드가 전부 같은 잘못된 라벨을 달게 됩니다. 이 일이 일어나는 동안 아무것도 던져지지 않습니다. 레코드는 파싱됐고, 수식은 재컴파일됐고, 파일은 경고 없이 저장됐으며, 결함은 삼주 뒤 통화 변환이 있던 자리에 0의 열이 나타난 사람에 의해 표면화됩니다. [MS-XLS] §2.4.271은 자기 참조, same-sheet 참조, 애드인 함수 컨테이너, 가상 경로와 시트 이름 테이블을 가진 외부 통합 문서, DDE 또는 OLE 데이터 링크, 사용되지 않는 플레이스홀더가 될 수 있는 레코드를 기술합니다. 그리고 명세에 없지만 실제 디스크에 존재하는 일곱 번째 상태, 파싱되지 않는 레코드가 있습니다. 해결책은 더 나은 휴리스틱이 아니라 휴리스틱을 아예 갖지 않는 것입니다

SupBook 레코드가 실을 수 있는 일곱 종

HotXLS는 서포팅 링크 분류를 lxExternSheet.pas에서 닫힌 열거형으로 선언하고 모든 다운스트림 결정이 그것을 스위치합니다. 아홉 개의 열거값이 일곱 범주를 덮습니다. DDE와 OLE 경우는 해소되기 전에 잠정 상태가 필요하기 때문입니다

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // 파싱 실패, 또는 뒤따르는 바이트가 남았다
    slkSelf,              // 이 통합 문서
    slkSameSheet,         // U+0000 마커
    slkAddIn,             // 애드인 함수 컨테이너
    slkExternalWorkbook,  // 가상 경로 + 시트 이름 테이블
    slkDde,               // ExternName 플래그로 해소됨
    slkOle,               // ExternName 플래그로 해소됨
    slkDdeOrOle,          // 둘 중 하나, 아직 어느 쪽인지 모름
    slkUnused);           // 공백 하나짜리 플레이스홀더

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // 0 기반, ExternSheet.rgXTI에 저장된 그대로
    ExternID    : Integer;   // 1 기반, 내부 관례
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

디스패치는 문자열이 아니라 센티널 주도입니다. $0401 필드 값이 self 레코드를 표시합니다. 시트 수 하나에 $3A01이 짝지어지면 애드인 컨테이너입니다. 1부터 $00FF 범위의 값만이 인코딩된 가상 경로가 뒤따른다는 뜻이고, 그때에만 HotXLS가 문자열을 디코딩합니다. 그 세 형태 밖의 것은 slkUnknown에 머물고, 시트 이름 테이블이 레코드 본문을 정확히 소비하지 않는 레코드는 머리가 그럴듯해 보여도 slkUnknown으로 강등됩니다

HotXLS가 BIFF SupBook 레코드를 일곱 종으로 분류하는 데 쓰는 센티널 주도 사다리. 인코딩 경로 범위의 값에만 문자열을 디코딩하고 기본 분기 대신 미지 종으로 폴백한다
각 종은 문자열 검사가 아니라 센티널로 도달하며, 어느 형태와도 맞지 않는 레코드는 이 통합 문서를 뜻하는 기본 분기로 떨어지는 대신 미지로 남습니다

same-sheet 마커는 어째서 빈 문자열로 디코딩되는가

범용 BIFF 문자열 리더가 분류가 의존하는 바이트를 파괴하기 때문입니다. same-sheet 서포팅 링크는 단일 문자가 U+0000인 한 문자 문자열인데, TXLSBlob.GetBiffString은 그것을 빈 WideString으로 돌려줍니다. 진짜 빈 경로와 구별할 수 없고, 자기 참조 휴리스틱이 “self”라고 답하는 입력이 정확히 그것입니다. 그래서 HotXLS는 디코딩된 값을 믿는 대신 레코드 본문에서 날 첫 코드 포인트를 읽습니다

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // 압축, 한 바이트
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // 와이드, 두 바이트
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

압축 대 와이드 분기에 주목하십시오. 옵션 바이트는 문자열 헤더에서 고정 오프셋에 앉아 있고 첫 코드 포인트는 비트 0에 따라 한 바이트 또는 두 바이트입니다. 그래서 무조건 바이트로 읽으면 대부분의 파일에서 통하고 현지화된 빌드가 쓴 파일에서 실패합니다. 버그로서 가능한 가장 나쁜 분포입니다. 사용되지 않는 플레이스홀더는 같은 방식으로, 리터럴 공백 하나 페이로드로 잡히고, DDE 또는 OLE 경우는 인코딩된 경로에 박힌 U+0003 구분자로 잡힙니다

HotXLS가 디코딩된 문자열 대신 BIFF SupBook 레코드 본문에서 날 첫 코드 포인트를 읽는 이유. 범용 문자열 리더가 same-sheet U+0000 마커를 빈 값으로 만들기 때문이다
same-sheet 마커는 문자가 U+0000인 한 문자 문자열이므로, 범용 문자열 리더는 그것을 빈 값으로 접고 옵션 바이트 오프셋의 날 코드 포인트만 그것을 지킵니다

DDE와 OLE는 어째서 SupBook 시점에 분리될 수 없는가

SupBook 레코드가 구별 비트를 실지 않기 때문입니다. 그것은 링크가 둘 중 하나라고 말해 줄 뿐이고, 어느 쪽인지 결정하는 fOlefOleLink 플래그는 스트림에서 나중에 도착하는 ExternName 레코드($0023)에 삽니다. HotXLS는 파싱 시점에 slkDdeOrOle을 기록하고 ParseExternalName에서 좁히며, ExternName이 끝내 도착하지 않으면 종은 영원히 잠정으로 남습니다. 그것이 옳습니다. 파일이 실제로 말하지 않기 때문입니다. 모든 다운스트림 소비자는 그 잠정 값을 없는 값이 아니라 진짜 값으로 다루므로, 어떤 호출자도 타이브레이커를 발명할 필요가 없습니다. 여기서 “아마 DDE겠지”라고 추측하면 더 단정한 열거형과 아무도 거슬러 올라갈 수 없는 한 부류의 오답을 사게 됩니다

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

XTI 인덱스는 디스크에서 0 기반, 내부에서 1 기반

HotXLS는 off-by-one 변환을 토큰이 내부 구문 트리에 들어오는 지점에서 정확히 한 번 수행하고, 그 외 어디에서도 하지 않습니다. PtgNameX.ixti([MS-XLS] §2.5.198.85)는 ExternSheet 레코드($0017, §2.4.106)의 rgXTI 배열에 대한 0 기반 인덱스인 반면, 라이브러리 내부 ExternID 관례는 1 기반이고 0은 “외부 시트 없음”으로 예약되어 있습니다. BIFF8 읽기 경로는 tNameX 토큰을 디코딩할 때 FExternID := wValue + 1을 하고 쓰기 경로는 StoreExternID - 1을 내보내서, 날 토큰 뷰와 디스크상 의미론은 그대로 남습니다. 이것을 틀리면 잡아내기가 비정상적으로 어렵습니다. 외부 정의 이름은 옆 항목으로 해석되고, XTI 항목 하나짜리 파일에서 인덱스 0은 인덱스 1이 되고, 빗나가고, 이름이 조용히 저하됩니다. 재컴파일된 수식 텍스트만 운동하는 회귀 테스트는 결코 그것을 보지 못합니다. 재컴파일은 디스크 인덱스를 전혀 건드리지 않기 때문입니다. 시트와 통합 문서에 걸친 정의 이름을 실제 바이트 스트림으로 검증할 가치가 있게 만드는 것과 같은 함정입니다. 해석은 양쪽 끝에서 경계가 있습니다. TlxExternSheetSheet.TryResolveXti는 음수 인덱스나 없는 항목에 False를 돌려주고, TXLSSupBook.TryGetKind는 배열 밖 SupBook 인덱스에 False를 돌려주며, ClassifyXti는 그 다음 slkSelfslkSameSheetfrcInternal로, slkExternalWorkbookfrcExternalWorkbook으로, slkAddIn, slkDde, slkOle, slkDdeOrOlefrcExternalOther로 매핑합니다. 그 외 전부, 범위 밖 경로 전부 포함, frcUnknownOrMalformed에 착지합니다

HotXLS가 BIFF PtgNameX 토큰의 0 기반 XTI 인덱스를 단일 지점에서 1 기반 내부 ExternID로 변환하는 모습. 양쪽 끝의 경계 있는 해석과 그것을 소비하는 분류 맵
0 기반 디스크 인덱스와 1 기반 내부 ExternID 사이의 off-by-one은 토큰이 구문 트리에 들어올 때 한 번 적용되고, 해석 불가능한 모든 인덱스는 기형 클래스에 착지합니다

수식을 얼리기 전에 분류하기

TXLSCompiledFormula.ClassifyReferences는 수식을 디컴파일해 대괄호를 찾는 대신 보존된 BIFF 토큰 스트림을 직접 훑습니다. 수식 텍스트에서 대괄호 사냥은 파서의 외투를 걸친 텍스트 휴리스틱입니다. 문자열 리터럴과 매치되고, 구조적 참조와 매치되고, 디컴파일 형태에서 대괄호를 실지 않는 외부 정의 이름은 완전히 놓칩니다. 토큰 스캔은 PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d, PtgAreaErr3d만 보고, BIFF 스트림이 살아남지 않으면 구문 트리 순회로 폴백합니다. 병합은 일부러 비관적입니다. 고정 우선순위는 frcUnknownOrMalformed, 그 다음 frcExternalWorkbook, 그 다음 frcExternalOther, 그 다음 frcInternal이므로, 읽을 수 없는 토큰 하나가 수식 전체를 오염시킵니다. 외부 정의 이름에 대해서는 이름 인덱스도 검증됩니다. 1 기반, 범위 안, 유지된 ExternName 레코드가 뒷받침

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets는 1 기반
    begin
      Sheet := Wb.Sheets[i];
      // frcExternalWorkbook으로 분류된 수식만 얼린다
      // 내부, 애드인, DDE/OLE, 기형 참조는 수식으로 남는다
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

OnlyExternal 매개변수가 분류가 자기 값을 돌려받는 지점입니다. 수식을 얼리는 것은 되돌릴 수 없으므로, 연산은 참조가 외부 통합 문서라고 의심하기만 하는 대신 증명해야 합니다. 애드인 호출은 살아남고, DDE와 OLE 링크는 살아남고, 파서가 완전히 이해하지 못한 것은 전부 살아남습니다. 불확실성의 안전한 귀결은 아무것도 바꾸지 않는 것이기 때문입니다. 같은 규율이 통합 문서 사이에서 복사된 수식의 재바인딩을 다스립니다. 잘못 분류된 참조는 요란히 실패하는 대신 잘못된 책에 재바인딩되는 곳이 바로 거기입니다

파싱되지 않는 레코드는 손대지 않고 다시 쓴다

HotXLS는 원본 SupBook 페이로드를 보존하고 레코드가 편집된 적 없으면 바이트 단위로 다시 내보냅니다. 파싱 실패는 slkUnknown을 설정하고 파생 상태를 지우지만, 캡처된 본문은 FRawData에 남고 저장 경로는 항목이 dirty가 아니고 self 레코드가 아닌 한 어떤 재구성보다 그것을 선호합니다. 대안 — 파서가 이해하지 못한 레코드를 기록기가 내보낼 잘 형성된 무언가가 있도록 자기 참조로 정규화하는 것 — 은 이해하지 못한 레코드를 명확히 틀린 레코드로 바꿉니다. 그 원칙은 로드-저장 사이클에 걸쳐 VBA 프로젝트와 그 외부 참조에 적용되는 것과 같은 계약이고, 실제 세계의 파일을 라운드트립하는 라이브러리와 자기 테스트 스위트가 우연히 담고 있는 파일을 라운드트립하는 라이브러리의 차이입니다. 십오 년의 Excel 버전, 보고서 생성기, 마이그레이션 도구 둘을 통과한 통합 문서는 현재 살아 있는 누구도 설계하지 않은 레코드를 담고 있을 것입니다. 찾은 그대로 다시 쓰십시오

SupBook과 XTI 레코드의 타입 분류는 HotXLS 2.361.2부터 2.361.4에 걸쳐 출시되었고, 여기서 기술한 경계 있는 XTI 해석과 더 안전한 ConvertFormulasToValues 경로와 함께였습니다. 애드인 호출, DDE 또는 OLE 링크, 외부 정의 이름을 실은 레거시 xls 파일을 읽는 델파이나 C++Builder 코드를 유지 보수한다면, HotXLS Delphi spreadsheet component가 일곱 종 분류 전체를 네이티브로 처리합니다. 일을 수행하는 기계에 Excel 설치도 OLE 자동화도 없이