diff --git a/examples/chart_data_labels.py b/examples/chart_data_labels.py new file mode 100644 index 000000000..378481dc6 --- /dev/null +++ b/examples/chart_data_labels.py @@ -0,0 +1,240 @@ +####################################################################### +# +# A demo of an various Excel chart data label features that are available +# via an XlsxWriter chart. +# +# Copyright 2013-2020, John McNamara, jmcnamara@cpan.org +# +import xlsxwriter + +workbook = xlsxwriter.Workbook('chart_data_labels.xlsx') +worksheet = workbook.add_worksheet() +bold = workbook.add_format({'bold': 1}) + +# Add the worksheet data that the charts will refer to. +headings = ['Number', 'Data', 'Text'] + +data = [ + [2, 3, 4, 5, 6, 7], + [20, 10, 20, 30, 40, 30], + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], +] + +worksheet.write_row('A1', headings, bold) +worksheet.write_column('A2', data[0]) +worksheet.write_column('B2', data[1]) +worksheet.write_column('C2', data[2]) + +####################################################################### +# +# Example with standard data labels. +# + +# Create a Column chart. +chart1 = workbook.add_chart({'type': 'column'}) + +# Configure the first series with a polynomial trendline. +chart1.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True}, +}) + +# Add a chart title. +chart1.set_title({'name': 'Chart with standard data labels'}) + +# Turn off the chart legend. +chart1.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D2', chart1, {'x_offset': 25, 'y_offset': 10}) + +####################################################################### +# +# Example with value and category data labels. +# + +# Create a Column chart. +chart2 = workbook.add_chart({'type': 'column'}) + +# Configure the first series with a polynomial trendline. +chart2.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True, 'category': True}, +}) + +# Add a chart title. +chart2.set_title({'name': 'Category and Value data labels'}) + +# Turn off the chart legend. +chart2.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D18', chart2, {'x_offset': 25, 'y_offset': 10}) + +####################################################################### +# +# Example with standard data labels with different font. +# + +# Create a Column chart. +chart3 = workbook.add_chart({'type': 'column'}) + +# Configure the first series with a polynomial trendline. +chart3.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True, + 'font': {'bold': True, + 'color': 'red', + 'rotation': -30}}, +}) + +# Add a chart title. +chart3.set_title({'name': 'Data labels with user defined font'}) + +# Turn off the chart legend. +chart3.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D34', chart3, {'x_offset': 25, 'y_offset': 10}) + +####################################################################### +# +# Example with custom string data labels. +# + +# Create a Column chart. +chart4 = workbook.add_chart({'type': 'column'}) + +# Some custom labels. +custom_labels = [ + {'value': 'Amy'}, + {'value': 'Bea'}, + {'value': 'Eva'}, + {'value': 'Fay'}, + {'value': 'Liv'}, + {'value': 'Una'}, +] + +# Configure the first series with a polynomial trendline. +chart4.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True, 'custom': custom_labels}, +}) + +# Add a chart title. +chart4.set_title({'name': 'Chart with custom string data labels'}) + +# Turn off the chart legend. +chart4.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D50', chart4, {'x_offset': 25, 'y_offset': 10}) + +####################################################################### +# +# Example with custom data labels from cells. +# + +# Create a Column chart. +chart5 = workbook.add_chart({'type': 'column'}) + +# Some custom labels. +custom_labels = [ + {'value': '=Sheet1!$C$2'}, + {'value': '=Sheet1!$C$3'}, + {'value': '=Sheet1!$C$4'}, + {'value': '=Sheet1!$C$5'}, + {'value': '=Sheet1!$C$6'}, + {'value': '=Sheet1!$C$7'}, +] + +# Configure the first series with a polynomial trendline. +chart5.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True, 'custom': custom_labels}, +}) + +# Add a chart title. +chart5.set_title({'name': 'Chart with custom data labels from cells'}) + +# Turn off the chart legend. +chart5.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D66', chart5, {'x_offset': 25, 'y_offset': 10}) + +####################################################################### +# +# Example with custom and default data labels. +# + +# Create a Column chart. +chart6 = workbook.add_chart({'type': 'column'}) + +# Some custom labels. The undef items will get the default value. +# We also set a font for the custom items as an extra example. +custom_labels = [ + {'value': '=Sheet1!$C$2', 'font': {'color': 'red'}}, + None, + {'value': '=Sheet1!$C$4', 'font': {'color': 'red'}}, + {'value': '=Sheet1!$C$5', 'font': {'color': 'red'}}, +] + +# Configure the first series with a polynomial trendline. +chart6.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True, 'custom': custom_labels}, +}) + +# Add a chart title. +chart6.set_title({'name': 'Mixed custom and default data labels'}) + +# Turn off the chart legend. +chart6.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D82', chart6, {'x_offset': 25, 'y_offset': 10}) + + +####################################################################### +# +# Example with deleted custom data labels. +# + +# Create a Column chart. +chart7 = workbook.add_chart({'type': 'column'}) + +# Some deleted custom labels and defaults (undef). This allows us to +# highlight certain values such as the minimum and maximum. +custom_labels = [ + {'delete': True}, + None, + {'delete': True}, + {'delete': True}, + None, + {'delete': True}, +] + +# Configure the first series with a polynomial trendline. +chart7.add_series({ + 'categories': '=Sheet1!$A$2:$A$7', + 'values': '=Sheet1!$B$2:$B$7', + 'data_labels': {'value': True, 'custom': custom_labels}, +}) + +# Add a chart title. +chart7.set_title({'name': 'Chart with deleted data labels'}) + +# Turn off the chart legend. +chart7.set_legend({'none': True}) + +# Insert the chart into the worksheet (with an offset). +worksheet.insert_chart('D98', chart7, {'x_offset': 25, 'y_offset': 10}) + +workbook.close() diff --git a/xlsxwriter/chart.py b/xlsxwriter/chart.py index bf8fc8e48..e95bdba8e 100644 --- a/xlsxwriter/chart.py +++ b/xlsxwriter/chart.py @@ -1173,6 +1173,8 @@ def _get_labels_properties(self, labels): data_id = self._get_data_id(formula, label.get('data')) label['data_id'] = data_id + label['font'] = self._convert_font_args(label.get('font')) + return labels def _get_area_properties(self, options): @@ -2682,7 +2684,7 @@ def _write_legend(self): self._write_overlay() if font: - self._write_tx_pr(None, font) + self._write_tx_pr(font) # Write the c:spPr element. self._write_sp_pr(legend) @@ -2810,7 +2812,7 @@ def _write_title_formula(self, title, data_id, is_y_axis, font, layout, self._write_overlay() # Write the c:txPr element. - self._write_tx_pr(is_y_axis, font) + self._write_tx_pr(font, is_y_axis) self._xml_end_tag('c:title') @@ -2820,7 +2822,7 @@ def _write_tx_rich(self, title, is_y_axis, font): self._xml_start_tag('c:tx') # Write the c:rich element. - self._write_rich(title, is_y_axis, font) + self._write_rich(title, font, is_y_axis, is_data_label=False) self._xml_end_tag('c:tx') @@ -2848,7 +2850,7 @@ def _write_tx_formula(self, title, data_id): self._xml_end_tag('c:tx') - def _write_rich(self, title, is_y_axis, font): + def _write_rich(self, title, font, is_y_axis, is_data_label): # Write the element. if font and font.get('rotation'): @@ -2865,36 +2867,7 @@ def _write_rich(self, title, is_y_axis, font): self._write_a_lst_style() # Write the a:p element. - self._write_a_p_rich(title, font) - - self._xml_end_tag('c:rich') - - def _write_rich_label(self, title, font): - # Write the element for data labels. - - if font and font.get('rotation'): - rotation = font['rotation'] - else: - rotation = None - - self._xml_start_tag('c:rich') - - # Write the a:bodyPr element. - self._write_a_body_pr(rotation, None) - - # Write the a:lstStyle element. - self._write_a_lst_style() - - self._xml_start_tag('a:p') - - # Write the a:pPr element. - if font: - self._write_a_p_pr_rich(font) - - # Write the a:r element. - self._write_a_r(title, font) - - self._xml_end_tag('a:p') + self._write_a_p_rich(title, font, is_data_label) self._xml_end_tag('c:rich') @@ -2924,13 +2897,14 @@ def _write_a_lst_style(self): # Write the element. self._xml_empty_tag('a:lstStyle') - def _write_a_p_rich(self, title, font): + def _write_a_p_rich(self, title, font, is_data_label): # Write the element for rich string titles. self._xml_start_tag('a:p') # Write the a:pPr element. - self._write_a_p_pr_rich(font) + if not is_data_label: + self._write_a_p_pr_rich(font) # Write the a:r element. self._write_a_r(title, font) @@ -3046,7 +3020,7 @@ def _write_a_t(self, title): self._xml_data_element('a:t', title) - def _write_tx_pr(self, is_y_axis, font): + def _write_tx_pr(self, font, is_y_axis=False): # Write the element. if font and font.get('rotation'): @@ -3539,7 +3513,7 @@ def _write_d_lbls(self, labels): # Write the custom c:dLbl elements. if labels.get('custom'): - self._write_custom_labels(labels['custom']) + self._write_custom_labels(labels, labels['custom']) # Write the c:numFmt element. if labels.get('num_format'): @@ -3583,7 +3557,7 @@ def _write_d_lbls(self, labels): self._xml_end_tag('c:dLbls') - def _write_custom_labels(self, labels): + def _write_custom_labels(self, parent, labels): # Write the element. index = 0 @@ -3602,12 +3576,30 @@ def _write_custom_labels(self, labels): if delete_label: self._write_delete(1) + elif label.get('formula'): self._write_custom_label_formula(label) - self._write_show_val() + + if parent.get('value'): + self._write_show_val() + if parent.get('category'): + self._write_show_cat_name() + if parent.get('series_name'): + self._write_show_ser_name() + elif label.get('value'): self._write_custom_label_str(label) - self._write_show_val() + + if parent.get('value'): + self._write_show_val() + if parent.get('category'): + self._write_show_cat_name() + if parent.get('series_name'): + self._write_show_ser_name() + else: + if label['font']: + self._xml_empty_tag('c:spPr') + self._write_tx_pr(label['font']) self._xml_end_tag('c:dLbl') @@ -3622,7 +3614,7 @@ def _write_custom_label_str(self, label): self._xml_start_tag('c:tx') # Write the c:rich element. - self._write_rich_label(title, font) + self._write_rich(title, font, is_y_axis=False, is_data_label=True) self._xml_end_tag('c:tx') @@ -3645,6 +3637,10 @@ def _write_custom_label_formula(self, label): self._xml_end_tag('c:tx') + if label['font']: + self._xml_empty_tag('c:spPr') + self._write_tx_pr(label['font']) + def _write_show_legend_key(self): # Write the element. val = '1' @@ -3770,7 +3766,7 @@ def _write_d_table(self): if table['font']: # Write the table font. - self._write_tx_pr(None, table['font']) + self._write_tx_pr(table['font']) self._xml_end_tag('c:dTable') diff --git a/xlsxwriter/test/comparison/test_chart_data_labels26.py b/xlsxwriter/test/comparison/test_chart_data_labels26.py index ac49a37b5..9cfb90540 100644 --- a/xlsxwriter/test/comparison/test_chart_data_labels26.py +++ b/xlsxwriter/test/comparison/test_chart_data_labels26.py @@ -43,7 +43,7 @@ def test_create_file(self): chart.add_series({ 'values': '=Sheet1!$A$1:$A$5', - 'data_labels': {'value': 1, 'custom': [{'value': 33}]} + 'data_labels': {'value': True, 'custom': [{'value': 33}]} }) chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) diff --git a/xlsxwriter/test/comparison/test_chart_data_labels27.py b/xlsxwriter/test/comparison/test_chart_data_labels27.py index 9ebbb262f..7fef0e11a 100644 --- a/xlsxwriter/test/comparison/test_chart_data_labels27.py +++ b/xlsxwriter/test/comparison/test_chart_data_labels27.py @@ -43,7 +43,7 @@ def test_create_file(self): chart.add_series({ 'values': '=Sheet1!$A$1:$A$5', - 'data_labels': {'value': 1, 'custom': [{'value': '=Sheet1!$D$1'}]} + 'data_labels': {'value': True, 'custom': [{'value': '=Sheet1!$D$1'}]} }) chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) diff --git a/xlsxwriter/test/comparison/test_chart_data_labels28.py b/xlsxwriter/test/comparison/test_chart_data_labels28.py index 1fd474b17..b283e7453 100644 --- a/xlsxwriter/test/comparison/test_chart_data_labels28.py +++ b/xlsxwriter/test/comparison/test_chart_data_labels28.py @@ -51,7 +51,7 @@ def test_create_file(self): chart.add_series({ 'values': '=Sheet1!$A$1:$A$5', - 'data_labels': {'value': 1, 'custom': custom} + 'data_labels': {'value': True, 'custom': custom} }) chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) diff --git a/xlsxwriter/test/comparison/test_chart_data_labels29.py b/xlsxwriter/test/comparison/test_chart_data_labels29.py index 39a05ce41..8ae2995a5 100644 --- a/xlsxwriter/test/comparison/test_chart_data_labels29.py +++ b/xlsxwriter/test/comparison/test_chart_data_labels29.py @@ -41,7 +41,7 @@ def test_create_file(self): chart.add_series({ 'values': '=Sheet1!$A$1:$A$5', - 'data_labels': {'value': 1, 'custom': [{'delete': 1}]} + 'data_labels': {'value': True, 'custom': [{'delete': 1}]} }) chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) diff --git a/xlsxwriter/test/comparison/test_chart_data_labels30.py b/xlsxwriter/test/comparison/test_chart_data_labels30.py index 6c729cf7a..92e881097 100644 --- a/xlsxwriter/test/comparison/test_chart_data_labels30.py +++ b/xlsxwriter/test/comparison/test_chart_data_labels30.py @@ -42,7 +42,7 @@ def test_create_file(self): chart.add_series({ 'values': '=Sheet1!$A$1:$A$5', 'data_labels': { - 'value': 1, + 'value': True, 'custom': [{'delete': True}, None, {'delete': True}, None, {'delete': True}] } }) diff --git a/xlsxwriter/test/comparison/test_chart_data_labels31.py b/xlsxwriter/test/comparison/test_chart_data_labels31.py new file mode 100644 index 000000000..6450c523a --- /dev/null +++ b/xlsxwriter/test/comparison/test_chart_data_labels31.py @@ -0,0 +1,56 @@ +############################################################################### +# +# Tests for XlsxWriter. +# +# Copyright (c), 2013-2019, John McNamara, jmcnamara@cpan.org +# + +from ..excel_comparison_test import ExcelComparisonTest +from ...workbook import Workbook + + +class TestCompareXLSXFiles(ExcelComparisonTest): + """ + Test file created by XlsxWriter against a file created by Excel. + + """ + + def setUp(self): + + self.set_filename('chart_data_labels31.xlsx') + + def test_create_file(self): + """Test the creation of a simple XlsxWriter file.""" + + workbook = Workbook(self.got_filename) + + worksheet = workbook.add_worksheet() + chart = workbook.add_chart({'type': 'column'}) + + chart.axis_ids = [71248896, 71373568] + + data = [ + [1, 2, 3, 4, 5], + [2, 4, 6, 8, 10], + [3, 6, 9, 12, 15], + [10, 20, 30, 40, 50], + ] + + worksheet.write_column('A1', data[0]) + worksheet.write_column('B1', data[1]) + worksheet.write_column('C1', data[2]) + worksheet.write_column('D1', data[3]) + + chart.add_series({ + 'values': '=Sheet1!$A$1:$A$5', + 'data_labels': {'value': True, 'category': True, 'series_name': True, 'custom': [{'value': 33}]} + }) + + chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) + chart.add_series({'values': '=Sheet1!$C$1:$C$5'}) + + worksheet.insert_chart('E9', chart) + + workbook.close() + + self.assertExcelEqual() diff --git a/xlsxwriter/test/comparison/test_chart_data_labels32.py b/xlsxwriter/test/comparison/test_chart_data_labels32.py new file mode 100644 index 000000000..aa2c6a7a6 --- /dev/null +++ b/xlsxwriter/test/comparison/test_chart_data_labels32.py @@ -0,0 +1,56 @@ +############################################################################### +# +# Tests for XlsxWriter. +# +# Copyright (c), 2013-2019, John McNamara, jmcnamara@cpan.org +# + +from ..excel_comparison_test import ExcelComparisonTest +from ...workbook import Workbook + + +class TestCompareXLSXFiles(ExcelComparisonTest): + """ + Test file created by XlsxWriter against a file created by Excel. + + """ + + def setUp(self): + + self.set_filename('chart_data_labels32.xlsx') + + def test_create_file(self): + """Test the creation of a simple XlsxWriter file.""" + + workbook = Workbook(self.got_filename) + + worksheet = workbook.add_worksheet() + chart = workbook.add_chart({'type': 'column'}) + + chart.axis_ids = [71374336, 71414144] + + data = [ + [1, 2, 3, 4, 5], + [2, 4, 6, 8, 10], + [3, 6, 9, 12, 15], + [10, 20, 30, 40, 50], + ] + + worksheet.write_column('A1', data[0]) + worksheet.write_column('B1', data[1]) + worksheet.write_column('C1', data[2]) + worksheet.write_column('D1', data[3]) + + chart.add_series({ + 'values': '=Sheet1!$A$1:$A$5', + 'data_labels': {'value': True, 'custom': [{'value': 33, 'font': {'bold': True, 'italic': True, 'color': 'red', 'baseline': -1}}]} + }) + + chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) + chart.add_series({'values': '=Sheet1!$C$1:$C$5'}) + + worksheet.insert_chart('E9', chart) + + workbook.close() + + self.assertExcelEqual() diff --git a/xlsxwriter/test/comparison/test_chart_data_labels33.py b/xlsxwriter/test/comparison/test_chart_data_labels33.py new file mode 100644 index 000000000..33ebb043d --- /dev/null +++ b/xlsxwriter/test/comparison/test_chart_data_labels33.py @@ -0,0 +1,56 @@ +############################################################################### +# +# Tests for XlsxWriter. +# +# Copyright (c), 2013-2019, John McNamara, jmcnamara@cpan.org +# + +from ..excel_comparison_test import ExcelComparisonTest +from ...workbook import Workbook + + +class TestCompareXLSXFiles(ExcelComparisonTest): + """ + Test file created by XlsxWriter against a file created by Excel. + + """ + + def setUp(self): + + self.set_filename('chart_data_labels33.xlsx') + + def test_create_file(self): + """Test the creation of a simple XlsxWriter file.""" + + workbook = Workbook(self.got_filename) + + worksheet = workbook.add_worksheet() + chart = workbook.add_chart({'type': 'column'}) + + chart.axis_ids = [65546112, 70217728] + + data = [ + [1, 2, 3, 4, 5], + [2, 4, 6, 8, 10], + [3, 6, 9, 12, 15], + [10, 20, 30, 40, 50], + ] + + worksheet.write_column('A1', data[0]) + worksheet.write_column('B1', data[1]) + worksheet.write_column('C1', data[2]) + worksheet.write_column('D1', data[3]) + + chart.add_series({ + 'values': '=Sheet1!$A$1:$A$5', + 'data_labels': {'value': 1, 'custom': [{'font': {'bold': 1, 'italic': 1, 'baseline': -1}}]} + }) + + chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) + chart.add_series({'values': '=Sheet1!$C$1:$C$5'}) + + worksheet.insert_chart('E9', chart) + + workbook.close() + + self.assertExcelEqual() diff --git a/xlsxwriter/test/comparison/test_chart_data_labels34.py b/xlsxwriter/test/comparison/test_chart_data_labels34.py new file mode 100644 index 000000000..75e11913d --- /dev/null +++ b/xlsxwriter/test/comparison/test_chart_data_labels34.py @@ -0,0 +1,56 @@ +############################################################################### +# +# Tests for XlsxWriter. +# +# Copyright (c), 2013-2019, John McNamara, jmcnamara@cpan.org +# + +from ..excel_comparison_test import ExcelComparisonTest +from ...workbook import Workbook + + +class TestCompareXLSXFiles(ExcelComparisonTest): + """ + Test file created by XlsxWriter against a file created by Excel. + + """ + + def setUp(self): + + self.set_filename('chart_data_labels34.xlsx') + + def test_create_file(self): + """Test the creation of a simple XlsxWriter file.""" + + workbook = Workbook(self.got_filename) + + worksheet = workbook.add_worksheet() + chart = workbook.add_chart({'type': 'column'}) + + chart.axis_ids = [48497792, 48499712] + + data = [ + [1, 2, 3, 4, 5], + [2, 4, 6, 8, 10], + [3, 6, 9, 12, 15], + [10, 20, 30, 40, 50], + ] + + worksheet.write_column('A1', data[0]) + worksheet.write_column('B1', data[1]) + worksheet.write_column('C1', data[2]) + worksheet.write_column('D1', data[3]) + + chart.add_series({ + 'values': '=Sheet1!$A$1:$A$5', + 'data_labels': {'value': 1, 'custom': [{'value': '=Sheet1!$D$1', 'font': {'bold': 1, 'italic': 1, 'color': 'red', 'baseline': -1}}]} + }) + + chart.add_series({'values': '=Sheet1!$B$1:$B$5'}) + chart.add_series({'values': '=Sheet1!$C$1:$C$5'}) + + worksheet.insert_chart('E9', chart) + + workbook.close() + + self.assertExcelEqual() diff --git a/xlsxwriter/test/comparison/xlsx_files/chart_data_labels31.xlsx b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels31.xlsx new file mode 100644 index 000000000..fe5201ff3 Binary files /dev/null and b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels31.xlsx differ diff --git a/xlsxwriter/test/comparison/xlsx_files/chart_data_labels32.xlsx b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels32.xlsx new file mode 100644 index 000000000..42aebd2d2 Binary files /dev/null and b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels32.xlsx differ diff --git a/xlsxwriter/test/comparison/xlsx_files/chart_data_labels33.xlsx b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels33.xlsx new file mode 100644 index 000000000..61dcb2dac Binary files /dev/null and b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels33.xlsx differ diff --git a/xlsxwriter/test/comparison/xlsx_files/chart_data_labels34.xlsx b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels34.xlsx new file mode 100644 index 000000000..e75470504 Binary files /dev/null and b/xlsxwriter/test/comparison/xlsx_files/chart_data_labels34.xlsx differ