2010年11月17日星期三

微软NLS文件格式

最近一直在纠缠Ruby的字符串编码问题,其中就涉及到了CP936、CP950和CP951等代码页的码表。想说与其去翻不知道靠谱与否的资料,不如直接从系统里的NLS文件中提取数据,这又牵涉到了NLS的文件格式问题。

网上能找到的NLS文件格式信息很少,Konstantin Kazarnovsky童鞋在2002年写的一篇是其中最详细的了。不过比对一下c_936.nls等双字节编码发现,那篇东西错处还是不少,表格也很不知所云。于是打开WinHex猜了老半天,算是有了一点成果吧。

注:NT和非NT系统的NLS文件格式有所不同,下面的内容只适用于NT系统内的NLS文件。

以下是文件头信息,还蛮简单的:

地址 字节长 备注
0x00 2 文件标志,NT内的NLS应为0x000D(注一)
0x02 2 代码页,如936
0x04 2 1表示单字节编码,2表示双字节编码
0x06 8 四个0x003F,疑似为转换表中的无效值(注二)
0x0E 12 前导字节(注三)

注一:Win9X内的NLS文件中,该字段为0表示SBCS编码,为1表示DBCS编码。

注二:根据ANSI/OEM代码页以及SBCS/DBCS的不同,NLS文件中最多会有四张转换表,此处的四个DWORD很可能表示四张表中无对应字符时的值,不过貌似所有NLS文件此处都是四个0x003F就是。另外Win32中CPINFOEX结构体内有一个UnicodeDefaultChar成员似乎也都返回0x003F,或许就是这么来的。

注三:针对DBCS编码,此处保存第一字节可能的取值,SBCS编码该部分全为0,DBCS如CP936就是0x81和0xFE。取这12个字节的最小值并左移8位,如CP936就是0x8100,下面提到的CP2UC表DBCS部分,就是从这个值开始保存的。呃,至少CP936和CP950是这样。

接下来是Codepage To Unicode(下称CP2UC)和Unicode To Codepage(下称UC2CP)转换表。根据ANSI/OEM代码页以及SBCS/DBCS的不同,转换表一共可能有二到四张。

地址 字节长 备注
0x1A 2 CP2UC转换表长度(@t_len),类型和单位都是DWORD(注一)
0x1C 512 CP2UC转换表,0x00到0xFF部分,每字符2字节
0x021C 6 / 2 疑似分隔符,ANSI SBCS代码页此处6字节长,否则2字节长(注二)
 0x021E 512 CP2UC转换表,OEM部分,每字符2字节,ANSI SBCS代码页无此部分
 0x041E 4 / 2 疑似分隔符,OEM SBCS代码页此处4字节长,否则2字节长
  0x0420 (@t_len - 515)*2 CP2UC转换表,双字节部分,每字符2字节,非DBCS代码表无此部分
  -(注三) 2 疑似分隔符,非DBCS代码表无此部分
0x0222 / 0x0422 / - 65536 / 131072 UC2CP转换表,SBCS代码页每字符1字节,否则每字符2字节

注一:该值其实就三种,ANSI SBCS为0x103,OEM SBCS为0x203,其他则为DBCS。

注二:间隔符共6字节。CP2UC可能有1到3张表,每两张表之间间隔2字节,末尾补齐6字节。间隔符并不总是0,是否有啥含义未知。

注三:嗯,随便注一下,0x420 + (@t_len - 515)*2,谁不会算呢?

上表貌似画得挺复杂,可我已经尽力了……

从文件格式来看,NLS最多只能处理二字节编码,而GB18030最长需要4个字节,难怪CP54936不可能成为系统代码页。而且NLS格式的Unicode部分只覆盖了BMP,但较新版本的Big5-HKSCS有相当一部分却是在SIP(0x2XXXX),恐怕这也是CP951只能支持到Big5-HKSCS:2001的原因。

再次感叹一下,Ruby 1.9居然还在跟ANSI纠缠,实在是馊到不能再馊的馊主意,真不知道那几枚大神究竟是怎么想的。

最后是代码,随便参考一下吧:

require 'stringio'

module OtNtH;module FileFormat
  class MS_NLS
    attr_reader :codepage, :bytes_pre_char, :leadbytes
    attr_reader :oem_to_uc, :cp_to_uc, :uc_to_cp
    
    def initialize(fpath)
      s = StringIO.new(File.open(fpath, 'rb') { |f|f.read }, 'rb')
      def s.read_i2
        self.readbyte + (self.readbyte << 8)
      end

      # 文件头标志,0x0d表示NT格式的NLS
      buf = s.read_i2
      if buf == 0 then
        raise 'Win9x single-byte (SBCS) NLS Format!'
      elsif buf == 1 then
        raise 'Win9x double-byte (DBCS) NLS Format!'
      elsif buf != 0x0d
        raise 'Unknow File Format!'
      end
      
      @codepage = s.read_i2
      @bytes_pre_char = s.read_i2 # 为1则是SBCS单字节编码,为2则是DBCS
      
      s.pos += 8 # 0x003F * 4,可能是CPINFOEX里边的UnicodeDefaultChar
      
      buf = s.read(12) # 前导字节,如cp936是81和fe
      @leadbytes = []
      buf.strip.each_byte { |b| @leadbytes.push(b.to_s(16)) }
      
      @t_len = s.read_i2 # 转换表长度,cp936为32771
      # 0x0103表示单字节ANSI代码页,0x0203表示单字节OEM代码页
      # 为其他值时应该都是DBCS代码页
      raise 'Broken NLS File?' if s.size != s.pos + @t_len*2 + 0x10000*@bytes_pre_char
      
      @oem_to_uc = {}
      @cp_to_uc = {}
      @uc_to_cp = {}
      
      # 所有代码页都有的一张表
      0x100.times { |n| @cp_to_uc[sprintf('0x%.4X', n)] = sprintf('0x%.4X', s.read_i2) }
      
      while true do
        if @t_len == 0x103 then
          s.pos += 6
          break
        end
        
        s.pos += 2 # 未知内容,437为1,936和950都是0
        0x100.times { |n| @oem_to_uc[sprintf('0x%.4X', n)] = sprintf('0x%.4X', s.read_i2) }
        if @t_len == 0x203 then
          s.pos += 4
          break
        end
        
        s.pos += 2 # 未知内容,cp936是0
        b = (@leadbytes.min + '00').to_i(16)
        # 256 + 1 + 256 + 1 + .... + 1
        (@t_len-515).times { |n| @cp_to_uc[sprintf('0x%.4X', b+n)] = sprintf('0x%.4X', s.read_i2) }
        
        s.pos += 2 # 未知内容,936和950为4
        break
      end
      read_unit = @bytes_pre_char == 1 ? :readbyte : :read_i2
      0x10000.times { |n| @uc_to_cp[sprintf('0x%.4X', n)] = sprintf('0x%.4X', s.send(read_unit))}
      
    end
  end
end;end

nls = OtNtH::FileFormat::MS_NLS.new(ARGV[0])
ms = {}
nls.cp_to_uc.each do |cp, uc|
  n = uc.hex
  next if uc == '0x003F' || cp.hex == n || n == 0
  ms[cp] = uc.to_i(16)
end

n = 0
ms.each do |cp, uc|
  n += 1 if cp.hex != nls.uc_to_cp['0x%.4X' % uc].hex
end
puts n

2010年11月14日星期日

Encoding,还是Encoding

话说,上次发现台湾用big5-uao很多,于是和某台湾人士讨论了一下,而下面要说的问题就是在讨论过程中遇到的。

嗯,该问题虽然还是跟Encoding有关,不过这次倒不能说是Ruby的错。

以下是代码:

require 'mechanize'

agent = Mechanize.new
# 请自备梯子
agent.set_proxy('127.0.0.1', '8118')
# 该页面有用big5-uao编码的字符串
url = 'http://www.ptt.cc/man/Japanese-B95/index.html'
page = agent.get(url)
t = page.root.css('#finds > p')[0]

puts t.text.encoding
p t.text
p t.text.encode('gbk')
puts t.to_s.encoding
p t.to_s
p t.to_s.encode('gbk')

# 以下为程序输出:

#UTF-8
#"\u60A8\u73FE\u5728\u7684\u4F4D\u7F6E\u662F Japanese-B95 -          "
#"您現在的位置是 Japanese-B95 -          "
#Big5
#"<p>\x{B17A}\x{B27B}\x{A662}\x{AABA}\x{A6EC}\x{B86D}\x{AC4F} Japanese-B95 -          </p>"
#"<p>您現在的位置是 Japanese-B95 -          </p>"
#[#<Nokogiri::XML::SyntaxError: input conversion failed due to input error, bytes 0x93 0xAB 0xC7 0x66>, #<Nokogiri::XML::SyntaxError: input conversion failed due to input error, bytes 0x93 0xAB 0xC7 0x66>, #<Nokogiri::XML::SyntaxError: htmlCheckEncoding: encoder error>, #<Nokogiri::XML::SyntaxError: input conversion failed due to input error, bytes 0x93 0xAB 0xC7 0x66>, #<Nokogiri::XML::SyntaxError: encoder error>]

以我对Mechanize和Nokogiri的理解,该脚本的输出很正常。因为Nokogiri在其内部使用libxml解析HTML,而libxml使用libiconv处理编码问题。libiconv用big5去解码big5-uao字符串并尝试转换为utf-8,遇到不认识的部分时中断处理并丢弃剩余的部分,libxml则补完HTML的关闭标签并返回给Nokogiri。

(注:用Nokogiri去抓有编码问题的HTML页面时并不会主动报错,因此必须去检查page.root.errors,这很重要。)

然而这么一个符合我认知的脚本在某人的Mac上却跑出了不一样的结果。以下是稍微修改了一点的代码:

puts t.text.encoding
p t.text.lines.first.force_encoding('utf-8')
p t.text.lines.first.force_encoding('utf-8').encode('gbk', :undef=>:replace)
puts t.to_s.encoding
p t.to_s.lines.first
p t.to_s.lines.first.force_encoding('big5-uao').encode('gbk')

# 以下为程序输出:

#ASCII-8BIT
#"\u60A8\u73FE\u5728\u7684\u4F4D\u7F6E\u662F Japanese-B95 -          \uE66B\uF735\uE6C3\uF735\u4E2D\n"
#"您現在的位置是 Japanese-B95 -          ????中\n"
#Big5
#"<p>\x{B17A}\x{B27B}\x{A662}\x{AABA}\x{A6EC}\x{B86D}\x{AC4F} Japanese-B95 -          \x93\x{ABC7}f\x94D\x{C766}\x{A4A4}\n"
#"<p>您現在的位置是 Japanese-B95 -          読み込み中\n"
#[]

t.text.encoding不知为何变成了ascii-8bit,内容倒仍然是utf-8,可是却包含无效字符。而且libxml解析该页面没有发现错误(page.root.errors为空),t.to_s还居然能拿到正确的内容。

伊妹来伊妹去,最后我们俩在gtalk上兜了半天圈子,终于找到了问题所在。原来,他的Nokogiri用的并不是libiconv,而是ICU(IBM的那个,OSX里边默认安装),就这样而已。

(插花,t.text.encoding变成ascii-8bit的问题应该是Nokogiri的bug,这个还没去追的说。)

big5-uao只是一个big5的扩展,也就是在big5的private use area里安排了很多本不存在于big5里的内容。libiconv用严格符合big5的码表去解码big5-uao,于是就出错了。

而ICU的处理策略跟libiconv不同。在ICU的big5码表中,将big5的PUA与Unicode的PUA一一对应(第二个脚本输出的“\uE66B\uF735\uE6C3\uF735”这四个字符就在PUA里)。这样做的好处是即使字符串内包含非法字符,big5到utf-8再到big5也能还原到原始字符串而不会丢失信息。坏处么,ICU和别的编码转换工具混用会变成一件非常危险的事情。

虽然非常偏门,不过对处理big5-uao有需要的同学,可以试试这个:http://www.boxcn.net/shared/iu0uo12l70

压缩包里有我弄的一个为libiconv增加big5-uao的补丁(该补丁非常初步,不过正确性应该不成问题)以及编译好的dll,还有libxml以及相关dll。如果是动态链接的库,换上libiconv就行,只是Windows里gem安装的Nokogiri是静态编译,要处理big5-uao还需要继续改。

打开Nokogiri里的lib/nokogiri/ffi/libxml.rb,开头的部分改成这样:

# :stopdoc:
module Nokogiri
  module LibXML
    extend FFI::Library
    if RbConfig::CONFIG['host_os'] =~ /(mswin|mingw)/i
      raise(RuntimeError, "Nokogiri requires JRuby 1.4.0 or later on Windows") if RUBY_PLATFORM =~ /java/ && JRUBY_VERSION < "1.4.0"

再把压缩包里的所有dll文件放到lib/ext/nokogiri下,gem装上ffi,就可以这么用了:

ENV['NOKOGIRI_FFI'] = '1'
require 'mechanize'

agent = Mechanize.new
agent.set_proxy('127.0.0.1', '8118')

url = 'http://www.ptt.cc/man/Japanese-B95/index.html'
page = agent.get(url)
page.encoding = 'big5-uao'

t = page.root.css('#finds > p')[0]
puts t.text.encoding # => ASCII-8BIT,这里还是不对,仍然需要force一下
puts t.to_s.encoding # => Big5-UAO

貌似我应该去找找text的问题,然后给Nokogiri提交两个补丁…

git代码库里的Nokogiri已经将ffi的部分移除,以后如果要处理big5-uao,看来只能自己编译Nokogiri了。

2010年11月7日星期日

Encoding,又是Encoding

话说,前几天在用Ruby的Mechanize抓一个台湾网站的时候,又遇到了一系列令人无语的事情。详细的过程我就不复述了,以下只是精简版。

假设我们用如下脚本:

agent = Mechanize.new
page = agent.get(url)
puts page.root.inner_html.encode('gbk') if page.root.css('#ln2').length == 0

去抓这么一个HTML页面:

<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=big5" />
<title>TT</title>
</head>

<body>
<p id="ln1">豬八戒照鏡子——裏外不是人</p>
<p id="ln2">/你看不見我/</p>
</body>
</html>

程序输出的结果是:

<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=big5">
<title>TT</title>
</head>
<body>
<p id="ln1">豬八戒照鏡子——</p>
</body>
</html>

大陆的童鞋们可能不熟,不过对岸的童鞋肯定一眼就能看出问题在哪里。最早的Big5是非常不完善的一种编码,缺字缺字符,譬如“裏”字就没有。而根据我从Linux里学来的经验,CP950应该靠谱。因为CP950就是Big5外加一些扩充字符,而且大部分HTML页面都是在Windows里写的。于是代码和输出变成这样:

agent = Mechanize.new
page = agent.get(url)
page.encoding = 'cp950'
node = page.root.css('#ln2')
puts node.text.encoding # => UTF-8
puts node.text.encode('gbk') # => /你看不見我/
puts puts node.inner_html # => 乱码,不过内容是对的
######### 以下是诡异的部分 ###########
puts node.inner_html.encoding # => Big5
puts node.inner_html.encode('gbk') # => Encoding::UndefinedConversionError: "\xA1\xFE" to UTF-8 in conversion from Big5 to UTF-8 to GBK
puts node.inner_html.force_encoding('cp950').encoding # => Big5

首先,node.text的值是对的,证明page.encoding的设置生效了,可node.inner_html.encoding为什么会是Big5?然后,force_encoding无效?

第一个问题,难道是先get再设置encoding所导致,也就是Mechanize的错?可mechanize/page.rb里的代码显示,Mechanize是无辜的:

def encoding=(encoding)
  @encoding = encoding

  if @parser
    parser_encoding = @parser.encoding
    if (parser_encoding && parser_encoding.downcase) != (encoding && encoding.downcase)
      # lazy reinitialize the parser with the new encoding
      @parser = nil
    end
  end

  encoding
end

既然Mechanize没问题,那就去翻Nokogiri吧。一层又一层追下去之后,在nokogiri/xml/node.rb里找到了这么一段:

def serialize *args, &block
  options = args.first.is_a?(Hash) ? args.shift : {
    :encoding   => args[0],
    :save_with  => args[1] || SaveOptions::FORMAT
  }

  encoding = options[:encoding] || document.encoding

  outstring = ""
  if encoding && outstring.respond_to?(:force_encoding)
    outstring.force_encoding(Encoding.find(encoding))
  end
  io = StringIO.new(outstring)
  write_to io, options, &block
  io.string
end

包括上面提到的force_encoding无效的问题,答案呼之欲出了:

puts Encoding.find('CP950') # => Big5
puts Encoding.aliases['CP950'] # => Big5
puts Encoding.aliases['CP936'] # => GBK

总结下来,问题有两个:

  1. CP936和GBK很相似,但还是有不一样的地方,CP950和Big5差得就更多了,Ruby却混为一谈;
  2. Nokogiri内部使用libxml解析HTML,但同样的编码名称在libxml和Ruby眼里不是一样的东西。

第一个问题我觉得应该能算作Bug。不过从Ruby 1.9 Feature #1784,还有这里这里看来,Big5的复杂和纠结远超我过去的了解。假如Big5-UAO的码表能兼容CP950的话,或许将Big5-UAO当成CP950来用也不错。

第二个问题再次说明了ANSI是多么万恶的一种东西。直接用Nokogiri的话还可以在parse前先用Iconv将HTML转换为utf-8躲过去,而Mechanize就只能打补丁了。

(Mechanize其实也应该打补丁,很多简体的HTML把Charset声明为GB2312,可想而知会有什么毛病。只是大陆UTF-8推广还不错,问题不彰而已。)

好吧,反正我对Ruby 1.9使用ANSI表示不理解也不是第一次了。